sentinelayer-cli 0.41.0 → 0.43.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 (45) hide show
  1. package/README.md +55 -0
  2. package/package.json +3 -2
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +779 -47
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/index.js +11 -2
  16. package/src/engram/ingest-document.js +107 -0
  17. package/src/engram/store.js +6 -1
  18. package/src/engram/tools.js +6 -0
  19. package/src/events/schema.js +18 -0
  20. package/src/legacy-cli.js +2 -2
  21. package/src/net/test-egress-guard.js +158 -0
  22. package/src/review/investor-dd-orchestrator.js +14 -2
  23. package/src/session/admission-access.js +256 -0
  24. package/src/session/admission-auth.js +319 -0
  25. package/src/session/admission-preimage.js +90 -0
  26. package/src/session/admission.js +641 -0
  27. package/src/session/checkpoints.js +25 -1
  28. package/src/session/codebase-context.js +54 -0
  29. package/src/session/coordination-guidance.js +1 -1
  30. package/src/session/daemon-spawn.js +20 -2
  31. package/src/session/daemon.js +7 -1
  32. package/src/session/event-identity.js +9 -2
  33. package/src/session/first-message.js +11 -3
  34. package/src/session/listener.js +3 -0
  35. package/src/session/live-source.js +47 -16
  36. package/src/session/message-edits.js +89 -0
  37. package/src/session/recap.js +26 -4
  38. package/src/session/remote-hydrate.js +7 -19
  39. package/src/session/store.js +13 -14
  40. package/src/session/stream.js +23 -3
  41. package/src/session/sync.js +128 -11
  42. package/src/session/tickets.js +316 -0
  43. package/src/session/title-sync.js +2 -1
  44. package/src/session/usage-report.js +41 -2
  45. package/src/session/wake/resolve-target.js +10 -5
@@ -91,6 +91,34 @@ import {
91
91
  normalizeSessionOnboarding,
92
92
  writeSessionOnboardingBrief,
93
93
  } from "../session/invitations.js";
94
+ import {
95
+ cancelAdmission,
96
+ fetchOwnAdmissionReceipt,
97
+ parseScope as parseAdmissionScope,
98
+ parseTtlSeconds as parseAdmissionTtlSeconds,
99
+ runAdmissionJoin,
100
+ } from "../session/admission.js";
101
+ import {
102
+ decideSessionAdmission,
103
+ getSessionAdmission,
104
+ listSessionAdmissions,
105
+ revokeSessionAdmission,
106
+ setSessionAdmissionMode,
107
+ } from "../session/admission-access.js";
108
+ import {
109
+ assertCanonicalAgentId,
110
+ canonicalAgentId,
111
+ currentAdmittedAgent,
112
+ withAgentAdmission,
113
+ } from "../session/admission-auth.js";
114
+ import {
115
+ claimTicket,
116
+ listTickets,
117
+ releaseTicketLease,
118
+ renewTicketLease,
119
+ submitTicket,
120
+ ticketEvents,
121
+ } from "../session/tickets.js";
94
122
  import { postFirstSentiMessage } from "../session/first-message.js";
95
123
  import { createListenerHostWake } from "../session/wake/listen-wake.js";
96
124
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
@@ -104,6 +132,8 @@ import {
104
132
  import { readSessionPreview } from "../session/preview.js";
105
133
  import {
106
134
  createSessionMessageAction,
135
+ editSessionMessage,
136
+ fetchJsonWithFullTimeout,
107
137
  fetchSessionUsageLedger,
108
138
  fetchSessionPinnedMessages,
109
139
  listSessionMessageActions,
@@ -120,6 +150,7 @@ import {
120
150
  updateSessionReadCursor,
121
151
  } from "../session/sync.js";
122
152
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
153
+ import { messageRevision, replyRevisionEvent, sessionMessageRoutingEvent } from "../session/message-edits.js";
123
154
  import { runSessionRecall } from "../session/recall/index.js";
124
155
  import { mergeLiveSources } from "../session/live-source.js";
125
156
  import { listenSessionEvents } from "../session/listener.js";
@@ -573,11 +604,6 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
573
604
  command: "sl session action <id> disregard --target-sequence <n>",
574
605
  description: "Mark a message as intentionally ignored or superseded.",
575
606
  },
576
- {
577
- type: "view",
578
- command: "sl session view <id> <sequence>",
579
- description: "Manually backfill a read receipt for a target message; remote reads record views automatically.",
580
- },
581
607
  ]);
582
608
 
583
609
  function normalizeSessionMessageActionType(value) {
@@ -669,6 +695,11 @@ function buildSessionActionEvent(sessionId, action = {}) {
669
695
  },
670
696
  sessionId,
671
697
  ts: actionCreatedAt(action),
698
+ messageRevision: messageRevision(action),
699
+ editedAt: action.editedAt,
700
+ editedBy: action.editedBy,
701
+ canEdit: action.canEdit,
702
+ revisionEvidence: action.revisionEvidence,
672
703
  payload: {
673
704
  actionId: id,
674
705
  actionType,
@@ -676,6 +707,7 @@ function buildSessionActionEvent(sessionId, action = {}) {
676
707
  targetCursor: actionTargetCursor(action) || null,
677
708
  targetActionId: actionTargetActionId(action) || null,
678
709
  note: normalizeString(action.note) || null,
710
+ metadata: action.metadata && typeof action.metadata === "object" ? action.metadata : undefined,
679
711
  message: actionDisplayMessage(action),
680
712
  source: "session_action",
681
713
  },
@@ -1404,19 +1436,22 @@ async function findReusableSessionCandidate({
1404
1436
  // can exercise the local materialization path without a real API.
1405
1437
  async function fetchRemoteSessionDetail(endpoint, headers) {
1406
1438
  let response;
1439
+ let payload;
1407
1440
  try {
1408
- response = await fetch(endpoint, { method: "GET", headers });
1441
+ ({ response, payload } = await fetchJsonWithFullTimeout(
1442
+ endpoint, { method: "GET", headers, redirect: "error" }, 2_000,
1443
+ ));
1409
1444
  } catch (err) {
1410
1445
  return {
1411
1446
  ok: false,
1412
1447
  response: null,
1413
1448
  status: 0,
1414
- reason: normalizeString(err?.message) || "fetch_failed",
1449
+ reason: err?.name === "AbortError" ? "request_timeout" : (normalizeString(err?.message) || "fetch_failed"),
1415
1450
  retryable: true,
1416
1451
  };
1417
1452
  }
1418
1453
  if (response && response.ok) {
1419
- return { ok: true, response, status: response.status, reason: "", retryable: false };
1454
+ return { ok: true, payload, status: response.status, reason: "", retryable: false };
1420
1455
  }
1421
1456
  if (!response) {
1422
1457
  return { ok: false, response: null, status: 0, reason: "no_response", retryable: true };
@@ -1431,8 +1466,7 @@ async function fetchRemoteSessionDetail(endpoint, headers) {
1431
1466
  };
1432
1467
  }
1433
1468
 
1434
- async function parseRemoteSessionDetailResponse(response) {
1435
- const body = await response.json().catch(() => ({}));
1469
+ function parseRemoteSessionDetailResponse(body) {
1436
1470
  return body && body.session && typeof body.session === "object"
1437
1471
  ? body.session
1438
1472
  : body && typeof body === "object"
@@ -1475,7 +1509,7 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1475
1509
  return {
1476
1510
  ok: true,
1477
1511
  source: "singleton",
1478
- session: await parseRemoteSessionDetailResponse(detail.response),
1512
+ session: parseRemoteSessionDetailResponse(detail.payload),
1479
1513
  status: detail.status,
1480
1514
  };
1481
1515
  }
@@ -1769,7 +1803,13 @@ export async function ensureWorkspaceSession({
1769
1803
  );
1770
1804
  let titleSync = null;
1771
1805
  if (shouldPushTitle) {
1772
- titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
1806
+ titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, {
1807
+ targetPath,
1808
+ // Startup uses the already validated credential; rotation and Retry-After
1809
+ // sleeps belong to explicit auth/title operations, not optional startup.
1810
+ autoRotate: false,
1811
+ maxRetries: 0,
1812
+ });
1773
1813
  }
1774
1814
 
1775
1815
  return {
@@ -1788,11 +1828,8 @@ export async function ensureWorkspaceSession({
1788
1828
  }
1789
1829
 
1790
1830
  function normalizeAgentId(value, fallbackValue = "cli-user") {
1791
- const normalized = normalizeString(value)
1792
- .toLowerCase()
1793
- .replace(/[^a-z0-9._-]+/g, "-")
1794
- .replace(/^-+|-+$/g, "");
1795
- return normalized || fallbackValue;
1831
+ // One canonicalisation for authorisation and execution (see admission-auth.js).
1832
+ return canonicalAgentId(value) || fallbackValue;
1796
1833
  }
1797
1834
 
1798
1835
  function canPublishListenerPresence(agentId) {
@@ -2389,6 +2426,11 @@ function formatEventLine(event = {}) {
2389
2426
  const ts = normalizeString(event.ts || event.timestamp);
2390
2427
  const type = normalizeString(event.event || event.type) || "event";
2391
2428
  const agentId = normalizeString(event.agent?.id || event.agentId || "unknown");
2429
+ if (type === "session_message_edited") {
2430
+ const routed = sessionMessageRoutingEvent(event);
2431
+ const target = event.payload?.targetActionId ? `reply ${event.payload.targetActionId}` : `#${event.payload?.targetSequenceId || "?"}`;
2432
+ return `${ts} ${agentId} ${type} ${target} revision ${event.payload?.messageRevision || "?"}: ${normalizeString(routed.payload?.message || routed.payload?.text || "")}`;
2433
+ }
2392
2434
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
2393
2435
  if (type === "session_action" || type === "session_reply" || type === "session_reaction") {
2394
2436
  const actionType = normalizeString(payload.actionType) || type.replace(/^session_/, "");
@@ -2809,6 +2851,43 @@ async function emitAgentKilledEvent(sessionId, agentId, {
2809
2851
  return event;
2810
2852
  }
2811
2853
 
2854
+ function collectOption(value, previous) {
2855
+ return [...(previous || []), value];
2856
+ }
2857
+
2858
+ /**
2859
+ * Wrap a command action so it runs as its agent: inside that agent's admission when
2860
+ * this machine holds one (every session API call then uses the admission credential
2861
+ * and never the human token), on the legacy path when it holds none, and refused when
2862
+ * what it holds is expired or unusable. `resolveIds` names the (session, agent) pair
2863
+ * exactly the way the action itself resolves it.
2864
+ */
2865
+ function asAdmittedAgent(resolveIds, action) {
2866
+ return async (...args) => {
2867
+ const { sessionId, agentId } = await resolveIds(...args);
2868
+ return withAgentAdmission(sessionId, agentId, () => action(...args));
2869
+ };
2870
+ }
2871
+
2872
+ async function sessionSayAgent(sessionId, _messageParts, options) {
2873
+ const sid = normalizeString(sessionId);
2874
+ const identity = await resolveSessionSayIdentity({
2875
+ sessionId: sid,
2876
+ agentId: options.agent,
2877
+ targetPath: path.resolve(process.cwd(), String(options.path || ".")),
2878
+ });
2879
+ return { sessionId: sid, agentId: identity.agentId };
2880
+ }
2881
+
2882
+ async function sessionReadAgent(sessionId, options) {
2883
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2884
+ return { sessionId: normalizeString(sessionId), agentId: await defaultAgentId(options.agent, targetPath) };
2885
+ }
2886
+
2887
+ async function sessionListenAgent(options) {
2888
+ return { sessionId: resolveSessionIdOption(options), agentId: normalizeAgentId(options.agent, "cli-user") };
2889
+ }
2890
+
2812
2891
  export function registerSessionCommand(program) {
2813
2892
  const session = program
2814
2893
  .command("session")
@@ -2923,8 +3002,9 @@ export function registerSessionCommand(program) {
2923
3002
  };
2924
3003
 
2925
3004
  // Best-effort admin visibility sync. Session creation remains local-first.
3005
+ let metadataSync = Promise.resolve(null);
2926
3006
  if (remoteSync.attempted) {
2927
- void syncSessionMetadataToApi(created.sessionId, {
3007
+ metadataSync = syncSessionMetadataToApi(created.sessionId, {
2928
3008
  targetPath,
2929
3009
  sessionId: created.sessionId,
2930
3010
  status: created.status,
@@ -2934,7 +3014,7 @@ export function registerSessionCommand(program) {
2934
3014
  ttlSeconds,
2935
3015
  template: created.template,
2936
3016
  codebaseContext: created.codebaseContext,
2937
- }).catch(() => {});
3017
+ }, { targetPath, timeoutMs: 2_000 }).catch(() => null);
2938
3018
  }
2939
3019
 
2940
3020
  // Make the session managed by default: spawn the Senti daemon as a
@@ -2971,6 +3051,14 @@ export function registerSessionCommand(program) {
2971
3051
  }).catch((error) => ({ posted: false, reason: normalizeString(error?.message) || "error" }));
2972
3052
  }
2973
3053
  payload.firstMessage = firstMessage;
3054
+ // Own the request lifecycle; do not leave a referenced fetch running
3055
+ // after emitting the single JSON document (even with --no-daemon).
3056
+ const metadataResult = await metadataSync;
3057
+ if (remoteSync.attempted) {
3058
+ remoteSync.status = metadataResult?.synced ? "synced" : "degraded";
3059
+ remoteSync.metadataSynced = Boolean(metadataResult?.synced);
3060
+ remoteSync.reason = metadataResult?.synced ? "metadata_acknowledged" : (metadataResult?.reason || "metadata_sync_failed");
3061
+ }
2974
3062
 
2975
3063
  if (shouldEmitJson(options, command)) {
2976
3064
  console.log(JSON.stringify(payload, null, 2));
@@ -3319,6 +3407,21 @@ export function registerSessionCommand(program) {
3319
3407
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
3320
3408
  .option("--model <model>", "Agent model hint", "cli")
3321
3409
  .option("--path <path>", "Workspace path for the session", ".")
3410
+ .option(
3411
+ "--goal <text>",
3412
+ "Request goal-and-scope admission: say what this agent is coming to do. A room owner approves it on the web; the agent then receives its own scoped, expiring identity.",
3413
+ )
3414
+ .option("--deliverable <text>", "An output this agent will produce (repeatable)", collectOption, [])
3415
+ .option("--stop-when <text>", "A condition that ends this agent's work (repeatable)", collectOption, [])
3416
+ .option(
3417
+ "--scope <actions>",
3418
+ "Comma-separated actions to request: session.read, session.post, session.react, tickets.read, tickets.work",
3419
+ )
3420
+ .option("--ttl <duration>", "How long the grant should last, e.g. 90m or 2h (5m..24h)", "2h")
3421
+ .option("--provider <name>", "Model provider hint for the admission request")
3422
+ .option("--no-wait", "Request admission and return immediately with the approval URL")
3423
+ .option("--wait-seconds <n>", "How long to wait for approval before returning pending", "900")
3424
+ .option("--cancel-admission", "Cancel this workspace's pending admission request for the session")
3322
3425
  .option("--json", "Emit machine-readable output")
3323
3426
  .action(async (sessionId, options, command) => {
3324
3427
  const normalizedSessionId = normalizeString(sessionId);
@@ -3329,6 +3432,34 @@ export function registerSessionCommand(program) {
3329
3432
  const explicitAgent = normalizeString(options.agent);
3330
3433
  const legacyName = normalizeString(options.name);
3331
3434
  const inviteToken = normalizeString(options.inviteToken);
3435
+ const emitJson = shouldEmitJson(options, command);
3436
+ // ONE requested identity for the whole join, validated before any side effect.
3437
+ // --name is the identity only when --agent is absent (its legacy alias); beside an
3438
+ // explicit --agent it is a free-text display label. Either way the identity is
3439
+ // used in its canonical spelling from here on, so the admission, its receipt,
3440
+ // the local registration and every later implicit command name the same agent.
3441
+ if (!explicitAgent) assertCanonicalAgentId(legacyName, "--name");
3442
+ const requestedAgent = canonicalAgentId(explicitAgent || legacyName);
3443
+ if (options.cancelAdmission) {
3444
+ const cancelled = await cancelAdmission(normalizedSessionId, {
3445
+ agentId: requestedAgent,
3446
+ targetPath,
3447
+ });
3448
+ const payload = {
3449
+ command: "session join",
3450
+ joined: false,
3451
+ sessionId: normalizedSessionId,
3452
+ admission: {
3453
+ status: cancelled?.status || "cancelled",
3454
+ admissionId: cancelled?.admissionId || null,
3455
+ },
3456
+ };
3457
+ if (emitJson) console.log(JSON.stringify(payload, null, 2));
3458
+ else console.log(`Admission request ${payload.admission.admissionId || ""} cancelled.`);
3459
+ return;
3460
+ }
3461
+ // Membership first. Only a member may ask for an agent to be admitted, so an
3462
+ // invitation is accepted BEFORE the admission request, never after.
3332
3463
  let invitationAccept = null;
3333
3464
  let invitationAcceptResult = null;
3334
3465
  if (inviteToken) {
@@ -3336,7 +3467,7 @@ export function registerSessionCommand(program) {
3336
3467
  targetPath,
3337
3468
  invitationToken: inviteToken,
3338
3469
  seatKey: options.seatKey,
3339
- agentId: explicitAgent || legacyName,
3470
+ agentId: requestedAgent,
3340
3471
  idempotencyKey: options.idempotencyKey,
3341
3472
  });
3342
3473
  invitationAcceptResult =
@@ -3344,6 +3475,65 @@ export function registerSessionCommand(program) {
3344
3475
  ? invitationAccept.result
3345
3476
  : null;
3346
3477
  }
3478
+ let admission = null;
3479
+ if (normalizeString(options.goal)) {
3480
+ const admissionAgent = requestedAgent;
3481
+ if (!admissionAgent) {
3482
+ throw new Error("--goal requires --agent <id>: admission is requested for one named agent.");
3483
+ }
3484
+ const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
3485
+ admission = await runAdmissionJoin(normalizedSessionId, {
3486
+ agentId: admissionAgent,
3487
+ displayName: legacyName && explicitAgent ? legacyName : "",
3488
+ model: options.model,
3489
+ provider: options.provider,
3490
+ goal: options.goal,
3491
+ deliverables: options.deliverable || [],
3492
+ stopConditions: options.stopWhen || [],
3493
+ actions: parseAdmissionScope(options.scope),
3494
+ ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
3495
+ wait: options.wait !== false,
3496
+ waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
3497
+ targetPath,
3498
+ onPending: ({ approveUrl, phase }) => {
3499
+ // stderr, so --json stdout stays a single parseable document.
3500
+ const line = phase === "identity"
3501
+ ? "Approval received; waiting for the AIdenID email and signed purpose receipt."
3502
+ : `Waiting for a room owner to approve this agent: ${terminalText(approveUrl, 1024)}`;
3503
+ if (emitJson) process.stderr.write(`${line}\n`);
3504
+ // Keep the line containing a remote URL free of all terminal control
3505
+ // bytes. `terminalText()` strips controls from the value itself; adding
3506
+ // local ANSI colour around it would make that guarantee untestable and
3507
+ // lets OSC-looking input hide inside a coloured terminal span.
3508
+ else if (phase === "identity") console.log(pc.yellow(line));
3509
+ else console.log(line);
3510
+ },
3511
+ });
3512
+ if (admission.status !== "active") {
3513
+ // Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
3514
+ // registers the agent or materializes the room for it.
3515
+ process.exitCode = ["pending", "approved"].includes(admission.status) ? 3 : 4;
3516
+ const payload = {
3517
+ command: "session join",
3518
+ joined: false,
3519
+ sessionId: normalizedSessionId,
3520
+ agentId: admissionAgent,
3521
+ invitationAccepted: Boolean(invitationAcceptResult),
3522
+ admission,
3523
+ };
3524
+ if (emitJson) {
3525
+ console.log(JSON.stringify(payload, null, 2));
3526
+ } else if (admission.status === "pending") {
3527
+ const approval = terminalText(admission.approveUrl, 1024) || "(see the session page)";
3528
+ console.log(`Admission pending. A room owner approves it here: ${approval}`);
3529
+ } else if (admission.status === "approved") {
3530
+ console.log(pc.yellow("Admission approved; AIdenID identity evidence is still provisioning. The agent was not joined."));
3531
+ } else {
3532
+ console.log(pc.red(`Admission ${terminalText(admission.status, 16) || "unknown"}. The agent was not joined.`));
3533
+ }
3534
+ return;
3535
+ }
3536
+ }
3347
3537
 
3348
3538
  // PR #483 contract: verify the session exists and the caller has access
3349
3539
  // BEFORE materializing local cache state. Without this we'd silently
@@ -3355,7 +3545,48 @@ export function registerSessionCommand(program) {
3355
3545
  // before the invite is accepted the user may legitimately receive 403,
3356
3546
  // so --invite-token performs the guarded accept mutation first and this
3357
3547
  // verification proves membership immediately afterward.
3358
- const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
3548
+ // An admitted agent proves its grant is live with ITS OWN credential, via the
3549
+ // grant's receipt, and never with the human token. A cached "active" is not
3550
+ // reported until this succeeds (a revoked grant is not joined), and nothing
3551
+ // about the room is fetched on the human's authority.
3552
+ const admittedAgentId = admission ? requestedAgent : "";
3553
+ let admissionReceipt = null;
3554
+ if (admission) {
3555
+ try {
3556
+ admissionReceipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3557
+ fetchOwnAdmissionReceipt(normalizedSessionId, { targetPath }),
3558
+ );
3559
+ // The grant must be for EXACTLY the identity this join registers, byte for
3560
+ // byte: a receipt for any other spelling is a different agent's authority.
3561
+ const receiptAgent = String(admissionReceipt?.agentId ?? "");
3562
+ if (receiptAgent !== admittedAgentId) {
3563
+ throw new Error(
3564
+ `the admission is for agent "${receiptAgent || "(none)"}", not "${admittedAgentId}"`,
3565
+ );
3566
+ }
3567
+ } catch (error) {
3568
+ process.exitCode = 4;
3569
+ const payload = {
3570
+ command: "session join",
3571
+ joined: false,
3572
+ sessionId: normalizedSessionId,
3573
+ agentId: admittedAgentId,
3574
+ admission: {
3575
+ ...admission,
3576
+ status: "not_accepted",
3577
+ verified: false,
3578
+ reason: error instanceof Error ? error.message : String(error),
3579
+ },
3580
+ };
3581
+ if (emitJson) console.log(JSON.stringify(payload, null, 2));
3582
+ else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined.`));
3583
+ return;
3584
+ }
3585
+ admission = { ...admission, verified: true, receipt: admissionReceipt };
3586
+ }
3587
+ const verification = admission
3588
+ ? { ok: true, source: "admission_receipt", session: { sessionId: normalizedSessionId } }
3589
+ : await verifyRemoteSession(normalizedSessionId, { targetPath });
3359
3590
  if (!verification.ok) {
3360
3591
  if (verification.status === 404 || verification.reason === "not_found") {
3361
3592
  throw new Error(
@@ -3382,17 +3613,31 @@ export function registerSessionCommand(program) {
3382
3613
  skipRemoteProbe: true,
3383
3614
  remoteSession,
3384
3615
  });
3385
- const joinHydration = await hydrateJoinBriefingContext(normalizedSessionId, {
3386
- targetPath,
3387
- });
3616
+ // History only for a grant that may read it, and then on the admission.
3617
+ const joinHydration = !admission
3618
+ ? await hydrateJoinBriefingContext(normalizedSessionId, { targetPath })
3619
+ : (admissionReceipt?.actions || []).includes("session.read")
3620
+ ? await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3621
+ hydrateJoinBriefingContext(normalizedSessionId, { targetPath }),
3622
+ )
3623
+ : { skipped: true, reason: "the grant does not include session.read" };
3388
3624
 
3389
3625
  const acceptedOnboarding = normalizeSessionOnboarding(
3390
3626
  invitationAcceptResult?.onboarding,
3391
3627
  invitationAcceptResult?.claimedSeat,
3392
3628
  );
3393
3629
  const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
3394
- const agentSeed = explicitAgent || acceptedAgentId || legacyName;
3630
+ // An admitted join registers the admitted identity and nothing else. Otherwise an
3631
+ // invitation's onboarding agent id is the identity only when none was given, and
3632
+ // it is held to the same canonical rule before anything is registered locally.
3633
+ if (!admission && !explicitAgent && acceptedAgentId) {
3634
+ assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
3635
+ }
3636
+ const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
3395
3637
  const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
3638
+ if (admission && resolvedAgentId !== admittedAgentId) {
3639
+ throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
3640
+ }
3396
3641
  const roleWasExplicit = optionWasSetByCli(command, "role");
3397
3642
  const role =
3398
3643
  normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
@@ -3485,6 +3730,7 @@ export function registerSessionCommand(program) {
3485
3730
  jsonPath: onboardingBrief.jsonPath,
3486
3731
  }
3487
3732
  : null,
3733
+ admission,
3488
3734
  };
3489
3735
  if (shouldEmitJson(options, command)) {
3490
3736
  console.log(JSON.stringify(payload, null, 2));
@@ -3505,6 +3751,23 @@ export function registerSessionCommand(program) {
3505
3751
  console.log(pc.green(`Invitation accepted${seatLabel}`));
3506
3752
  }
3507
3753
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
3754
+ if (admission) {
3755
+ if (admission.status === "active") {
3756
+ const expires = new Date(admission.grant.expiresAt * 1000).toISOString();
3757
+ console.log(
3758
+ pc.green(
3759
+ `Admitted${admission.reused ? " (existing grant)" : ""}: ${admission.grant.actions.join(", ")} until ${expires}`,
3760
+ ),
3761
+ );
3762
+ if (admission.identity?.subject) console.log(pc.gray(`identity=${admission.identity.subject}`));
3763
+ console.log(pc.gray(`credential stored at ${admission.credential.storage} (never printed)`));
3764
+ } else if (admission.status === "pending") {
3765
+ console.log(pc.yellow(`Admission pending approval: ${admission.approveUrl}`));
3766
+ console.log(pc.gray("Re-run the same command to resume waiting."));
3767
+ } else {
3768
+ console.log(pc.red(`Admission ${admission.status}.`));
3769
+ }
3770
+ }
3508
3771
  if (payload.onboardingGuide?.markdownPath) {
3509
3772
  console.log(pc.gray(`onboarding=${payload.onboardingGuide.markdownPath}`));
3510
3773
  }
@@ -3544,7 +3807,7 @@ export function registerSessionCommand(program) {
3544
3807
  .option("--local-only", "Append only to the local session cache without remote send confirmation")
3545
3808
  .option("--path <path>", "Workspace path for the session", ".")
3546
3809
  .option("--json", "Emit machine-readable output")
3547
- .action(async (sessionId, messageParts, options, command) => {
3810
+ .action(asAdmittedAgent(sessionSayAgent, async (sessionId, messageParts, options, command) => {
3548
3811
  const normalizedSessionId = normalizeString(sessionId);
3549
3812
  if (!normalizedSessionId) {
3550
3813
  throw new Error("session id is required.");
@@ -3672,7 +3935,7 @@ export function registerSessionCommand(program) {
3672
3935
  console.error(pc.yellow(`Identity warning: ${identity.identityWarning}`));
3673
3936
  }
3674
3937
  console.log(formatEventLine(persisted));
3675
- });
3938
+ }));
3676
3939
 
3677
3940
  session
3678
3941
  .command("post-agent <sessionId> <message>")
@@ -3962,7 +4225,20 @@ export function registerSessionCommand(program) {
3962
4225
  console.log(formatEventLine(persisted));
3963
4226
  });
3964
4227
 
3965
- async function runMessageActionCommand({
4228
+ // Every message-action command (react, reply, ack, ...) runs as its authoring
4229
+ // agent: inside that agent's admission when this machine holds one.
4230
+ async function runMessageActionCommand(args = {}) {
4231
+ const sid = normalizeString(args.sessionId);
4232
+ const identity = await resolveMessageActionIdentity({
4233
+ sessionId: sid,
4234
+ optionAgent: args.options?.agent,
4235
+ targetPath: path.resolve(process.cwd(), String(args.options?.path || ".")),
4236
+ env: process.env,
4237
+ });
4238
+ return withAgentAdmission(sid, identity.agentId, () => runMessageActionCommandUnscoped(args));
4239
+ }
4240
+
4241
+ async function runMessageActionCommandUnscoped({
3966
4242
  sessionId,
3967
4243
  actionType,
3968
4244
  options,
@@ -4193,20 +4469,50 @@ export function registerSessionCommand(program) {
4193
4469
  });
4194
4470
 
4195
4471
  session
4196
- .command("view <sessionId> <targetSequenceId>")
4197
- .description("Manually backfill a read receipt for a target session event")
4198
- .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
4199
- .option("--idempotency-key <key>", "Explicit idempotency key")
4472
+ .command("edit <sessionId> <target> <message...>")
4473
+ .description("Edit your message by sequence or threaded reply UUID; preserves its audit history")
4474
+ .option("--agent <id>", "Authoring agent (defaults to the joined session agent)")
4475
+ .option("--expected-revision <n>", "Guard against concurrent edits; otherwise fetch the current revision once")
4476
+ .option("--idempotency-key <key>", "Reuse an explicit key when retrying the same edit")
4200
4477
  .option("--path <path>", "Workspace path for the session", ".")
4201
4478
  .option("--json", "Emit machine-readable output")
4202
- .action(async (sessionId, targetSequenceId, options, command) => {
4203
- await runMessageActionCommand({
4204
- sessionId,
4205
- actionType: "view",
4206
- options,
4207
- command,
4208
- commandName: "session view",
4209
- targetSequenceId: parsePositiveInteger(targetSequenceId, "targetSequenceId", 0),
4479
+ .action(async (sessionId, target, messageParts, options, command) => {
4480
+ const sid = normalizeString(sessionId);
4481
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4482
+ const text = Array.isArray(messageParts) ? messageParts.join(" ") : String(messageParts || "");
4483
+ const sequence = /^\d+$/.test(target) ? Number(target) : null;
4484
+ if (sequence !== null && (!Number.isSafeInteger(sequence) || sequence <= 0)) throw new Error("target sequence must be a positive safe integer.");
4485
+ if (!sequence && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(target)) {
4486
+ throw new Error("Edit target must be a positive message sequence or a threaded reply UUID.");
4487
+ }
4488
+ const identity = await resolveMessageActionIdentity({ sessionId: sid, optionAgent: options.agent, targetPath, env: process.env });
4489
+ if (shouldBlockImplicitCliUserSessionSay(identity)) throw new Error(identity.identityWarning || "session edit requires an authoring agent identity; pass --agent <id>.");
4490
+ const expectedRevision = options.expectedRevision === undefined ? null : Number(options.expectedRevision);
4491
+ if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision <= 0)) throw new Error("expected-revision must be a positive safe integer.");
4492
+ return withAgentAdmission(sid, identity.agentId, async () => {
4493
+ const result = await editSessionMessage(sid, {
4494
+ targetPath,
4495
+ targetSequenceId: sequence,
4496
+ targetActionId: sequence ? "" : target,
4497
+ text,
4498
+ agentId: identity.agentId,
4499
+ expectedRevision,
4500
+ idempotencyKey: options.idempotencyKey,
4501
+ timeoutMs: 15_000,
4502
+ });
4503
+ if (!result.ok) throw new Error(`Session edit failed (${result.reason || "unknown"}). No local message was changed.`);
4504
+ let localAppend = { appended: 0, upgraded: 0, failed: 0 };
4505
+ try {
4506
+ await ensureLocalSessionForRemoteCommand(sid, { targetPath });
4507
+ const current = result.reply ? replyRevisionEvent(sid, result.reply) : result.event;
4508
+ localAppend = await appendMissingRemoteEvents(sid, [current, result.notification].filter(Boolean), { targetPath });
4509
+ } catch {
4510
+ localAppend = { appended: 0, upgraded: 0, failed: 1 };
4511
+ }
4512
+ const payload = { command: "session edit", sessionId: sid, target, ...result, localAppend, localAppendComplete: localAppend.failed === 0 };
4513
+ if (shouldEmitJson(options, command)) console.log(JSON.stringify(payload, null, 2));
4514
+ else console.log(`${result.changed ? "Edited" : "Confirmed"} ${sequence ? `#${sequence}` : `reply ${target}`} at revision ${messageRevision(result.reply || result.event)}.${localAppend.failed ? " Remote edit succeeded; local cache needs sync." : ""}`);
4515
+ return payload;
4210
4516
  });
4211
4517
  });
4212
4518
 
@@ -4951,7 +5257,7 @@ export function registerSessionCommand(program) {
4951
5257
  `Total log files to retain including the active file (default ${DEFAULT_ROTATING_LOG_MAX_FILES})`,
4952
5258
  String(DEFAULT_ROTATING_LOG_MAX_FILES),
4953
5259
  )
4954
- .action(async (options) => {
5260
+ .action(asAdmittedAgent(sessionListenAgent, async (options) => {
4955
5261
  const normalizedSessionId = resolveSessionIdOption(options);
4956
5262
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4957
5263
  const agentId = normalizeAgentId(options.agent, "cli-user");
@@ -5043,9 +5349,15 @@ export function registerSessionCommand(program) {
5043
5349
  options.maxPolls === undefined
5044
5350
  ? null
5045
5351
  : parsePositiveInteger(options.maxPolls, "max-polls", 1);
5046
- const listenTransport = requestedTransport === "auto" && maxPolls !== null
5047
- ? "poll"
5048
- : requestedTransport;
5352
+ if (currentAdmittedAgent() && requestedTransport === "stream") {
5353
+ throw new Error(
5354
+ "An admitted agent listens by polling: the event stream is not part of an admission grant, so a revoke never has to chase an open stream. Use --transport poll.",
5355
+ );
5356
+ }
5357
+ const listenTransport =
5358
+ requestedTransport === "auto" && (maxPolls !== null || currentAdmittedAgent())
5359
+ ? "poll"
5360
+ : requestedTransport;
5049
5361
  const since = options.since === undefined ? undefined : String(options.since);
5050
5362
  if (options.fromNow && options.since !== undefined) {
5051
5363
  throw new Error("Use either --from-now or --since, not both.");
@@ -5367,7 +5679,7 @@ export function registerSessionCommand(program) {
5367
5679
  }).catch(() => {});
5368
5680
  }
5369
5681
  }
5370
- });
5682
+ }));
5371
5683
 
5372
5684
  session
5373
5685
  .command("daemon [sessionId]")
@@ -5939,7 +6251,7 @@ export function registerSessionCommand(program) {
5939
6251
  .option("--include-control-events", "Include listener lifecycle/control-plane events in transcript output")
5940
6252
  .option("--path <path>", "Workspace path for the session", ".")
5941
6253
  .option("--json", "Emit machine-readable output")
5942
- .action(async (sessionId, options, command) => {
6254
+ .action(asAdmittedAgent(sessionReadAgent, async (sessionId, options, command) => {
5943
6255
  const normalizedSessionId = normalizeString(sessionId);
5944
6256
  if (!normalizedSessionId) {
5945
6257
  throw new Error("session id is required.");
@@ -6283,7 +6595,7 @@ export function registerSessionCommand(program) {
6283
6595
  console.log(formatEventLine(event));
6284
6596
  }
6285
6597
  }
6286
- });
6598
+ }));
6287
6599
 
6288
6600
  session
6289
6601
  .command("search <sessionId> <query>")
@@ -6492,6 +6804,426 @@ export function registerSessionCommand(program) {
6492
6804
  }
6493
6805
  });
6494
6806
 
6807
+ // ------------------------------------------------------- agent access
6808
+ // Human control plane for the Agent Admission Contract. The request command
6809
+ // delegates to the established agent-side flow; list/decision/revoke use the
6810
+ // matching owner-only REST routes. Goal text is an agent claim and is always
6811
+ // rendered as bounded, untrusted data.
6812
+ const access = session
6813
+ .command("access")
6814
+ .description("Request and govern AIdenID-backed, scoped agent access to a session");
6815
+
6816
+ function accessOutput(options, command, payload, render) {
6817
+ if (shouldEmitJson(options, command)) {
6818
+ console.log(JSON.stringify(payload, null, 2));
6819
+ return;
6820
+ }
6821
+ render();
6822
+ }
6823
+
6824
+ function terminalText(value, maxLength = 240) {
6825
+ let result = "";
6826
+ for (const ch of normalizeString(value).normalize("NFC")) {
6827
+ // Refuse terminal control/invisible formatting from remote values,
6828
+ // including ANSI/OSC initiators and bidi overrides/isolates.
6829
+ result += /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}]/u.test(ch) ? " " : ch;
6830
+ if (result.length >= maxLength) break;
6831
+ }
6832
+ return result.replace(/\s+/gu, " ").trim();
6833
+ }
6834
+
6835
+ function renderAdmissionLine(admission) {
6836
+ const requested = Array.isArray(admission?.requestedScope?.actions)
6837
+ ? terminalText(admission.requestedScope.actions.join(","), 180)
6838
+ : "scope unavailable";
6839
+ const ttl = Number(admission?.requestedScope?.ttlSeconds);
6840
+ const duration = Number.isFinite(ttl) ? ` for ${ttl}s` : "";
6841
+ const identity = terminalText(admission?.displayName, 120) || terminalText(admission?.agentId, 64) || "unknown-agent";
6842
+ console.log(`${terminalText(admission?.admissionId, 64) || "unknown"} ${terminalText(admission?.status, 16) || "unknown"} ${identity}`);
6843
+ console.log(pc.gray(` requested: ${requested}${duration}`));
6844
+ const summary = terminalText(admission?.goal?.summary);
6845
+ if (summary) console.log(pc.gray(` claimed goal (untrusted): ${JSON.stringify(summary)}`));
6846
+ if (admission?.email) {
6847
+ // Human terminal output is routinely copied into logs and support
6848
+ // transcripts. Keep the address in the explicit --json response only.
6849
+ const inboxStatus = terminalText(admission.email.status, 32) || "unknown";
6850
+ console.log(pc.gray(` AIdenID inbox: ${inboxStatus}`));
6851
+ }
6852
+ }
6853
+
6854
+ access
6855
+ .command("mode <sessionId> <mode>")
6856
+ .description("Set whether this room requires admission for agent-attributed access (required or legacy)")
6857
+ .option("--idempotency-key <key>", "Retry the same logical mode change safely")
6858
+ .option("--path <path>", "Workspace path for auth context", ".")
6859
+ .option("--json", "Emit machine-readable output")
6860
+ .action(async (sessionId, mode, options, command) => {
6861
+ const sid = normalizeString(sessionId);
6862
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6863
+ const changed = await setSessionAdmissionMode(sid, mode, {
6864
+ idempotencyKey: options.idempotencyKey,
6865
+ targetPath,
6866
+ });
6867
+ accessOutput(
6868
+ options,
6869
+ command,
6870
+ {
6871
+ command: "session access mode",
6872
+ idempotencyKey: changed.idempotencyKey,
6873
+ ...changed.result,
6874
+ },
6875
+ () => console.log(
6876
+ `Agent admission mode for ${terminalText(sid, 64)}: ${terminalText(changed.result.agentAdmissionMode, 16) || "unknown"}.`,
6877
+ ),
6878
+ );
6879
+ });
6880
+
6881
+ access
6882
+ .command("request <sessionId>")
6883
+ .description("Request agent access; a human must approve before the agent can claim or join")
6884
+ .requiredOption("--agent <id>", "Canonical agent id requesting access")
6885
+ .requiredOption("--goal <text>", "What the agent is coming to do")
6886
+ .option("--display-name <name>", "Human-readable agent name")
6887
+ .option("--deliverable <text>", "An output this agent will produce (repeatable)", collectOption, [])
6888
+ .option("--stop-when <text>", "A condition that ends this agent's work (repeatable)", collectOption, [])
6889
+ .option(
6890
+ "--scope <actions>",
6891
+ "Comma-separated actions: session.read, session.post, session.react, tickets.read, tickets.work",
6892
+ )
6893
+ .option("--ttl <duration>", "Requested grant lifetime, e.g. 90m or 24h (5m..24h)", "2h")
6894
+ .option("--model <model>", "Agent model hint", "cli")
6895
+ .option("--provider <name>", "Model provider hint")
6896
+ .option("--wait", "Wait for approval and claim the grant on this machine")
6897
+ .option("--wait-seconds <n>", "Maximum approval wait when --wait is set", "900")
6898
+ .option("--path <path>", "Workspace path for local admission state", ".")
6899
+ .option("--json", "Emit machine-readable output")
6900
+ .action(async (sessionId, options, command) => {
6901
+ const sid = normalizeString(sessionId);
6902
+ const agentId = canonicalAgentId(options.agent);
6903
+ assertCanonicalAgentId(options.agent, "--agent");
6904
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6905
+ const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
6906
+ if (!Number.isFinite(waitSeconds) || waitSeconds < 0) {
6907
+ throw new Error("--wait-seconds must be a non-negative integer.");
6908
+ }
6909
+ const admission = await runAdmissionJoin(sid, {
6910
+ agentId,
6911
+ displayName: options.displayName,
6912
+ model: options.model,
6913
+ provider: options.provider,
6914
+ goal: options.goal,
6915
+ deliverables: options.deliverable || [],
6916
+ stopConditions: options.stopWhen || [],
6917
+ actions: parseAdmissionScope(options.scope),
6918
+ ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
6919
+ wait: Boolean(options.wait),
6920
+ waitTimeoutMs: waitSeconds * 1000,
6921
+ targetPath,
6922
+ onPending: ({ approveUrl, phase }) => {
6923
+ const line = phase === "identity"
6924
+ ? "Approval received; waiting for the AIdenID email and signed purpose receipt."
6925
+ : `Waiting for a room owner to approve this agent: ${terminalText(approveUrl, 1024)}`;
6926
+ if (shouldEmitJson(options, command)) process.stderr.write(`${line}\n`);
6927
+ // A line carrying a remote URL must remain free of every terminal
6928
+ // control byte, including trusted colour wrappers. This mirrors the
6929
+ // guarded `session join` path and keeps raw support logs auditable.
6930
+ else if (phase === "identity") console.log(pc.yellow(line));
6931
+ else console.log(line);
6932
+ },
6933
+ });
6934
+ const payload = {
6935
+ command: "session access request",
6936
+ sessionId: sid,
6937
+ agentId,
6938
+ admission,
6939
+ readyToJoin: admission.status === "active",
6940
+ };
6941
+ accessOutput(options, command, payload, () => {
6942
+ if (admission.status === "pending") {
6943
+ console.log(`Access requested for ${agentId}; waiting for a room owner.`);
6944
+ if (admission.approveUrl) console.log(`Approval: ${terminalText(admission.approveUrl, 1024)}`);
6945
+ } else if (admission.status === "active") {
6946
+ console.log(`Access active for ${agentId}. The scoped credential is stored locally and was not printed.`);
6947
+ console.log(pc.gray("Run `sl session join` again with the same goal, scope, and TTL to attach this agent."));
6948
+ } else if (admission.status === "approved") {
6949
+ console.log("Access approved; waiting for the AIdenID inbox and signed purpose receipt before claim.");
6950
+ } else {
6951
+ console.log(`Access ${terminalText(admission.status, 16) || "unknown"} for ${agentId}.`);
6952
+ }
6953
+ });
6954
+ });
6955
+
6956
+ access
6957
+ .command("list <sessionId>")
6958
+ .description("List admission requests visible to a room owner or admin")
6959
+ .option("--status <status>", "Filter by admission status")
6960
+ .option("--path <path>", "Workspace path for auth context", ".")
6961
+ .option("--json", "Emit machine-readable output")
6962
+ .action(async (sessionId, options, command) => {
6963
+ const sid = normalizeString(sessionId);
6964
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6965
+ const result = await listSessionAdmissions(sid, {
6966
+ status: options.status,
6967
+ targetPath,
6968
+ });
6969
+ const payload = { command: "session access list", ...result };
6970
+ accessOutput(options, command, payload, () => {
6971
+ console.log(pc.gray(`Admission mode: ${terminalText(result.agentAdmissionMode, 16) || "unknown"}`));
6972
+ if (!Array.isArray(result.admissions) || result.admissions.length === 0) {
6973
+ console.log("No admission requests.");
6974
+ return;
6975
+ }
6976
+ for (const admission of result.admissions) renderAdmissionLine(admission);
6977
+ });
6978
+ });
6979
+
6980
+ access
6981
+ .command("status <sessionId> <admissionId>")
6982
+ .description("Show the requesting delegator's current admission status")
6983
+ .option("--path <path>", "Workspace path for auth context", ".")
6984
+ .option("--json", "Emit machine-readable output")
6985
+ .action(async (sessionId, admissionId, options, command) => {
6986
+ const sid = normalizeString(sessionId);
6987
+ const aid = normalizeString(admissionId);
6988
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6989
+ const result = await getSessionAdmission(sid, aid, { targetPath });
6990
+ accessOutput(
6991
+ options,
6992
+ command,
6993
+ { command: "session access status", sessionId: sid, ...result },
6994
+ () => {
6995
+ console.log(`${terminalText(result.admissionId, 64) || aid} ${terminalText(result.status, 16) || "unknown"} ${terminalText(result.agentId, 64) || "unknown-agent"}`);
6996
+ if (result.approveUrl) console.log(`Approval: ${terminalText(result.approveUrl, 1024)}`);
6997
+ },
6998
+ );
6999
+ });
7000
+
7001
+ access
7002
+ .command("approve <sessionId> <admissionId>")
7003
+ .description("Approve a pending request, optionally narrowing its scope or TTL")
7004
+ .option("--scope <actions>", "Comma-separated granted actions; must be a subset of the request")
7005
+ .option("--ttl <duration>", "Granted lifetime; may only shorten the request (5m..24h)")
7006
+ .option("--note <text>", "Approval note (max 500 characters)")
7007
+ .option("--idempotency-key <key>", "Retry the same logical approval safely")
7008
+ .option("--path <path>", "Workspace path for auth context", ".")
7009
+ .option("--json", "Emit machine-readable output")
7010
+ .action(async (sessionId, admissionId, options, command) => {
7011
+ const sid = normalizeString(sessionId);
7012
+ const aid = normalizeString(admissionId);
7013
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7014
+ const decision = await decideSessionAdmission(sid, aid, {
7015
+ decision: "approve",
7016
+ grantedActions: options.scope === undefined ? undefined : parseAdmissionScope(options.scope),
7017
+ ttlSeconds: options.ttl === undefined ? undefined : parseAdmissionTtlSeconds(options.ttl),
7018
+ note: options.note,
7019
+ idempotencyKey: options.idempotencyKey,
7020
+ targetPath,
7021
+ });
7022
+ const result = decision.result;
7023
+ accessOutput(
7024
+ options,
7025
+ command,
7026
+ { command: "session access approve", sessionId: sid, idempotencyKey: decision.idempotencyKey, admission: result },
7027
+ () => {
7028
+ console.log(`Approved ${terminalText(result.admissionId, 64) || aid} for ${terminalText(result.agentId, 64) || "the agent"}.`);
7029
+ if (result.email) {
7030
+ const inboxStatus = terminalText(result.email.status, 32) || "queued";
7031
+ console.log(pc.gray(`AIdenID inbox: ${inboxStatus}. Use --json to inspect its address.`));
7032
+ }
7033
+ },
7034
+ );
7035
+ });
7036
+
7037
+ access
7038
+ .command("deny <sessionId> <admissionId>")
7039
+ .description("Deny a pending agent admission request")
7040
+ .option("--note <text>", "Denial note (max 500 characters)")
7041
+ .option("--idempotency-key <key>", "Retry the same logical denial safely")
7042
+ .option("--path <path>", "Workspace path for auth context", ".")
7043
+ .option("--json", "Emit machine-readable output")
7044
+ .action(async (sessionId, admissionId, options, command) => {
7045
+ const sid = normalizeString(sessionId);
7046
+ const aid = normalizeString(admissionId);
7047
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7048
+ const decision = await decideSessionAdmission(sid, aid, {
7049
+ decision: "deny",
7050
+ note: options.note,
7051
+ idempotencyKey: options.idempotencyKey,
7052
+ targetPath,
7053
+ });
7054
+ accessOutput(
7055
+ options,
7056
+ command,
7057
+ { command: "session access deny", sessionId: sid, idempotencyKey: decision.idempotencyKey, admission: decision.result },
7058
+ () => console.log(`Denied ${terminalText(decision.result.admissionId, 64) || aid}.`),
7059
+ );
7060
+ });
7061
+
7062
+ access
7063
+ .command("revoke <sessionId> <admissionId>")
7064
+ .description("Revoke an approved or active agent admission")
7065
+ .option("--reason <text>", "Revocation reason (max 200 characters)")
7066
+ .option("--idempotency-key <key>", "Retry the same logical revocation safely")
7067
+ .option("--path <path>", "Workspace path for auth context", ".")
7068
+ .option("--json", "Emit machine-readable output")
7069
+ .action(async (sessionId, admissionId, options, command) => {
7070
+ const sid = normalizeString(sessionId);
7071
+ const aid = normalizeString(admissionId);
7072
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7073
+ const revoked = await revokeSessionAdmission(sid, aid, {
7074
+ reason: options.reason,
7075
+ idempotencyKey: options.idempotencyKey,
7076
+ targetPath,
7077
+ });
7078
+ accessOutput(
7079
+ options,
7080
+ command,
7081
+ { command: "session access revoke", sessionId: sid, idempotencyKey: revoked.idempotencyKey, admission: revoked.result },
7082
+ () => console.log(`Revoked ${terminalText(revoked.result.admissionId, 64) || aid}.`),
7083
+ );
7084
+ });
7085
+
7086
+ // ---------------------------------------------------------------- tickets
7087
+ // The room's shared backlog (API "Ticket Lease Contract (T2)"). Every subcommand is
7088
+ // an ACTOR command: the runCli choke point runs it on the agent's admission.
7089
+ const ticket = session
7090
+ .command("ticket")
7091
+ .description("The room's shared backlog: list, claim, renew, release and submit tickets");
7092
+
7093
+ // Ticket work is attributed by the CREDENTIAL, never by a label: the lease routes take the
7094
+ // holder from authentication alone. So an agent identity is allowed only with that agent's
7095
+ // live admission in scope; without it, the request would go out on the human token and
7096
+ // claim as the PERSON while this command reported the agent. Refused before any request.
7097
+ async function ticketIdentity(sessionId, options, targetPath) {
7098
+ const identity = await resolveSessionSayIdentity({ sessionId, agentId: options.agent, targetPath });
7099
+ const agent = normalizeString(identity.agentId);
7100
+ if (!agent || agent === "cli-user") return "human";
7101
+ const admitted = currentAdmittedAgent();
7102
+ if (!admitted || admitted.sessionId !== sessionId || admitted.agentId !== agent) {
7103
+ throw new Error(
7104
+ `Agent "${agent}" has no live admission in this room. Ticket work is attributed by the credential, so ` +
7105
+ `without one this would act as you, not as "${agent}". Admit it first: ` +
7106
+ `sl session join ${sessionId} --agent ${agent} --goal "<what it is here to do>"`
7107
+ );
7108
+ }
7109
+ return agent;
7110
+ }
7111
+
7112
+ function ticketOutput(options, command, payload, line) {
7113
+ if (shouldEmitJson(options, command)) {
7114
+ console.log(JSON.stringify(payload, null, 2));
7115
+ return;
7116
+ }
7117
+ console.log(line);
7118
+ }
7119
+
7120
+ ticket
7121
+ .command("list <sessionId>")
7122
+ .description("List the room's tickets (--available: only those claimable now)")
7123
+ .option("--available", "Only tickets that can be claimed now (never blocked work)")
7124
+ .option("--agent <id>", "Agent id reading the backlog (defaults to the joined session agent)")
7125
+ .option("--path <path>", "Workspace path for the session", ".")
7126
+ .option("--json", "Emit machine-readable output")
7127
+ .action(async (sessionId, options, command) => {
7128
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7129
+ const result = await listTickets(normalizeString(sessionId), { targetPath, available: Boolean(options.available) });
7130
+ const lines = result.items.map((t) => {
7131
+ const holder = t.lease ? ` · ${t.lease.live ? "leased by" : "lease EXPIRED, was"} ${t.lease.holder}` : "";
7132
+ const done = t.completion && !t.completion.verified ? " (manual, unverified)" : "";
7133
+ return `${t.id} ${t.status}${done} v${t.version} ${t.title}${holder}`;
7134
+ });
7135
+ ticketOutput(options, command, { command: "session ticket list", ...result }, lines.join("\n") || "No tickets.");
7136
+ });
7137
+
7138
+ ticket
7139
+ .command("claim <sessionId> <ticketId>")
7140
+ .description("Claim a ticket: take its fenced lease")
7141
+ .option("--expected-version <n>", "Refuse if the ticket has changed since this version")
7142
+ .option("--agent <id>", "Agent id claiming the ticket (defaults to the joined session agent)")
7143
+ .option("--path <path>", "Workspace path for the session", ".")
7144
+ .option("--json", "Emit machine-readable output")
7145
+ .action(async (sessionId, ticketId, options, command) => {
7146
+ const sid = normalizeString(sessionId);
7147
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7148
+ const identity = await ticketIdentity(sid, options, targetPath);
7149
+ const expectedVersion =
7150
+ options.expectedVersion === undefined ? null : parsePositiveInteger(options.expectedVersion, "expected-version", 1);
7151
+ const result = await claimTicket(sid, normalizeString(ticketId), { identity, expectedVersion, targetPath });
7152
+ ticketOutput(options, command, { command: "session ticket claim", identity, ...result },
7153
+ `Claimed ${ticketId} (fence ${result.lease.fence}, until ${result.lease.expiresAt}).`);
7154
+ });
7155
+
7156
+ ticket
7157
+ .command("renew <sessionId> <ticketId>")
7158
+ .description("Renew the lease this identity holds on a ticket")
7159
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
7160
+ .option("--path <path>", "Workspace path for the session", ".")
7161
+ .option("--json", "Emit machine-readable output")
7162
+ .action(async (sessionId, ticketId, options, command) => {
7163
+ const sid = normalizeString(sessionId);
7164
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7165
+ const identity = await ticketIdentity(sid, options, targetPath);
7166
+ const result = await renewTicketLease(sid, normalizeString(ticketId), { identity, targetPath });
7167
+ ticketOutput(options, command, { command: "session ticket renew", identity, ...result },
7168
+ `Renewed ${ticketId} until ${result.lease.expiresAt}.`);
7169
+ });
7170
+
7171
+ ticket
7172
+ .command("release <sessionId> <ticketId>")
7173
+ .description("Give a ticket back: it returns to open for the next claimant")
7174
+ .option("--reason <text>", "Why it is being released")
7175
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
7176
+ .option("--path <path>", "Workspace path for the session", ".")
7177
+ .option("--json", "Emit machine-readable output")
7178
+ .action(async (sessionId, ticketId, options, command) => {
7179
+ const sid = normalizeString(sessionId);
7180
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7181
+ const identity = await ticketIdentity(sid, options, targetPath);
7182
+ const result = await releaseTicketLease(sid, normalizeString(ticketId), {
7183
+ identity, targetPath, reason: normalizeString(options.reason) || null,
7184
+ });
7185
+ ticketOutput(options, command, { command: "session ticket release", identity, ...result }, `Released ${ticketId}.`);
7186
+ });
7187
+
7188
+ ticket
7189
+ .command("submit <sessionId> <ticketId>")
7190
+ .description("Submit the work for review (in_review). Completion needs verification; this is not done.")
7191
+ .option("--sha <sha>", "The implementation commit")
7192
+ .option("--evidence-file <path>", "JSON array of re-checkable evidence claims")
7193
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
7194
+ .option("--path <path>", "Workspace path for the session", ".")
7195
+ .option("--json", "Emit machine-readable output")
7196
+ .action(async (sessionId, ticketId, options, command) => {
7197
+ const sid = normalizeString(sessionId);
7198
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7199
+ const identity = await ticketIdentity(sid, options, targetPath);
7200
+ let evidence = [];
7201
+ if (options.evidenceFile) {
7202
+ evidence = JSON.parse(await fsp.readFile(path.resolve(targetPath, String(options.evidenceFile)), "utf8"));
7203
+ if (!Array.isArray(evidence)) throw new Error("--evidence-file must contain a JSON array.");
7204
+ }
7205
+ const result = await submitTicket(sid, normalizeString(ticketId), {
7206
+ identity, targetPath, implementationSha: normalizeString(options.sha) || null, evidence,
7207
+ });
7208
+ ticketOutput(options, command, { command: "session ticket submit", identity, ...result },
7209
+ `Submitted ${ticketId} for review.`);
7210
+ });
7211
+
7212
+ ticket
7213
+ .command("events <sessionId>")
7214
+ .description("The room's ticket events after a cursor, in commit order")
7215
+ .option("--after <cursor>", "Only events after this cursor", "0")
7216
+ .option("--agent <id>", "Agent id reading the feed (defaults to the joined session agent)")
7217
+ .option("--path <path>", "Workspace path for the session", ".")
7218
+ .option("--json", "Emit machine-readable output")
7219
+ .action(async (sessionId, options, command) => {
7220
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7221
+ const after = Number.parseInt(String(options.after ?? "0"), 10) || 0;
7222
+ const result = await ticketEvents(normalizeString(sessionId), { after, targetPath });
7223
+ const lines = (result.events || []).map((e) => `#${e.cursor} ${e.kind} ${e.ticketId} v${e.ticketVersion} by ${e.actorId}`);
7224
+ ticketOutput(options, command, { command: "session ticket events", ...result }, lines.join("\n") || "No new ticket events.");
7225
+ });
7226
+
6495
7227
  const checkpoint = session
6496
7228
  .command("checkpoint")
6497
7229
  .description("List, show, create, and generate durable session checkpoints");