sentinelayer-cli 0.41.0 → 0.42.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 (44) hide show
  1. package/README.md +18 -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 +483 -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-auth.js +312 -0
  24. package/src/session/admission-preimage.js +90 -0
  25. package/src/session/admission.js +620 -0
  26. package/src/session/checkpoints.js +25 -1
  27. package/src/session/codebase-context.js +54 -0
  28. package/src/session/coordination-guidance.js +1 -1
  29. package/src/session/daemon-spawn.js +20 -2
  30. package/src/session/daemon.js +7 -1
  31. package/src/session/event-identity.js +9 -2
  32. package/src/session/first-message.js +11 -3
  33. package/src/session/listener.js +3 -0
  34. package/src/session/live-source.js +47 -16
  35. package/src/session/message-edits.js +89 -0
  36. package/src/session/recap.js +26 -4
  37. package/src/session/remote-hydrate.js +7 -19
  38. package/src/session/store.js +13 -14
  39. package/src/session/stream.js +23 -3
  40. package/src/session/sync.js +128 -11
  41. package/src/session/tickets.js +316 -0
  42. package/src/session/title-sync.js +2 -1
  43. package/src/session/usage-report.js +41 -2
  44. package/src/session/wake/resolve-target.js +10 -5
@@ -91,6 +91,27 @@ 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
+ assertCanonicalAgentId,
103
+ canonicalAgentId,
104
+ currentAdmittedAgent,
105
+ withAgentAdmission,
106
+ } from "../session/admission-auth.js";
107
+ import {
108
+ claimTicket,
109
+ listTickets,
110
+ releaseTicketLease,
111
+ renewTicketLease,
112
+ submitTicket,
113
+ ticketEvents,
114
+ } from "../session/tickets.js";
94
115
  import { postFirstSentiMessage } from "../session/first-message.js";
95
116
  import { createListenerHostWake } from "../session/wake/listen-wake.js";
96
117
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
@@ -104,6 +125,8 @@ import {
104
125
  import { readSessionPreview } from "../session/preview.js";
105
126
  import {
106
127
  createSessionMessageAction,
128
+ editSessionMessage,
129
+ fetchJsonWithFullTimeout,
107
130
  fetchSessionUsageLedger,
108
131
  fetchSessionPinnedMessages,
109
132
  listSessionMessageActions,
@@ -120,6 +143,7 @@ import {
120
143
  updateSessionReadCursor,
121
144
  } from "../session/sync.js";
122
145
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
146
+ import { messageRevision, replyRevisionEvent, sessionMessageRoutingEvent } from "../session/message-edits.js";
123
147
  import { runSessionRecall } from "../session/recall/index.js";
124
148
  import { mergeLiveSources } from "../session/live-source.js";
125
149
  import { listenSessionEvents } from "../session/listener.js";
@@ -573,11 +597,6 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
573
597
  command: "sl session action <id> disregard --target-sequence <n>",
574
598
  description: "Mark a message as intentionally ignored or superseded.",
575
599
  },
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
600
  ]);
582
601
 
583
602
  function normalizeSessionMessageActionType(value) {
@@ -669,6 +688,11 @@ function buildSessionActionEvent(sessionId, action = {}) {
669
688
  },
670
689
  sessionId,
671
690
  ts: actionCreatedAt(action),
691
+ messageRevision: messageRevision(action),
692
+ editedAt: action.editedAt,
693
+ editedBy: action.editedBy,
694
+ canEdit: action.canEdit,
695
+ revisionEvidence: action.revisionEvidence,
672
696
  payload: {
673
697
  actionId: id,
674
698
  actionType,
@@ -676,6 +700,7 @@ function buildSessionActionEvent(sessionId, action = {}) {
676
700
  targetCursor: actionTargetCursor(action) || null,
677
701
  targetActionId: actionTargetActionId(action) || null,
678
702
  note: normalizeString(action.note) || null,
703
+ metadata: action.metadata && typeof action.metadata === "object" ? action.metadata : undefined,
679
704
  message: actionDisplayMessage(action),
680
705
  source: "session_action",
681
706
  },
@@ -1404,19 +1429,22 @@ async function findReusableSessionCandidate({
1404
1429
  // can exercise the local materialization path without a real API.
1405
1430
  async function fetchRemoteSessionDetail(endpoint, headers) {
1406
1431
  let response;
1432
+ let payload;
1407
1433
  try {
1408
- response = await fetch(endpoint, { method: "GET", headers });
1434
+ ({ response, payload } = await fetchJsonWithFullTimeout(
1435
+ endpoint, { method: "GET", headers, redirect: "error" }, 2_000,
1436
+ ));
1409
1437
  } catch (err) {
1410
1438
  return {
1411
1439
  ok: false,
1412
1440
  response: null,
1413
1441
  status: 0,
1414
- reason: normalizeString(err?.message) || "fetch_failed",
1442
+ reason: err?.name === "AbortError" ? "request_timeout" : (normalizeString(err?.message) || "fetch_failed"),
1415
1443
  retryable: true,
1416
1444
  };
1417
1445
  }
1418
1446
  if (response && response.ok) {
1419
- return { ok: true, response, status: response.status, reason: "", retryable: false };
1447
+ return { ok: true, payload, status: response.status, reason: "", retryable: false };
1420
1448
  }
1421
1449
  if (!response) {
1422
1450
  return { ok: false, response: null, status: 0, reason: "no_response", retryable: true };
@@ -1431,8 +1459,7 @@ async function fetchRemoteSessionDetail(endpoint, headers) {
1431
1459
  };
1432
1460
  }
1433
1461
 
1434
- async function parseRemoteSessionDetailResponse(response) {
1435
- const body = await response.json().catch(() => ({}));
1462
+ function parseRemoteSessionDetailResponse(body) {
1436
1463
  return body && body.session && typeof body.session === "object"
1437
1464
  ? body.session
1438
1465
  : body && typeof body === "object"
@@ -1475,7 +1502,7 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1475
1502
  return {
1476
1503
  ok: true,
1477
1504
  source: "singleton",
1478
- session: await parseRemoteSessionDetailResponse(detail.response),
1505
+ session: parseRemoteSessionDetailResponse(detail.payload),
1479
1506
  status: detail.status,
1480
1507
  };
1481
1508
  }
@@ -1769,7 +1796,13 @@ export async function ensureWorkspaceSession({
1769
1796
  );
1770
1797
  let titleSync = null;
1771
1798
  if (shouldPushTitle) {
1772
- titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
1799
+ titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, {
1800
+ targetPath,
1801
+ // Startup uses the already validated credential; rotation and Retry-After
1802
+ // sleeps belong to explicit auth/title operations, not optional startup.
1803
+ autoRotate: false,
1804
+ maxRetries: 0,
1805
+ });
1773
1806
  }
1774
1807
 
1775
1808
  return {
@@ -1788,11 +1821,8 @@ export async function ensureWorkspaceSession({
1788
1821
  }
1789
1822
 
1790
1823
  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;
1824
+ // One canonicalisation for authorisation and execution (see admission-auth.js).
1825
+ return canonicalAgentId(value) || fallbackValue;
1796
1826
  }
1797
1827
 
1798
1828
  function canPublishListenerPresence(agentId) {
@@ -2389,6 +2419,11 @@ function formatEventLine(event = {}) {
2389
2419
  const ts = normalizeString(event.ts || event.timestamp);
2390
2420
  const type = normalizeString(event.event || event.type) || "event";
2391
2421
  const agentId = normalizeString(event.agent?.id || event.agentId || "unknown");
2422
+ if (type === "session_message_edited") {
2423
+ const routed = sessionMessageRoutingEvent(event);
2424
+ const target = event.payload?.targetActionId ? `reply ${event.payload.targetActionId}` : `#${event.payload?.targetSequenceId || "?"}`;
2425
+ return `${ts} ${agentId} ${type} ${target} revision ${event.payload?.messageRevision || "?"}: ${normalizeString(routed.payload?.message || routed.payload?.text || "")}`;
2426
+ }
2392
2427
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
2393
2428
  if (type === "session_action" || type === "session_reply" || type === "session_reaction") {
2394
2429
  const actionType = normalizeString(payload.actionType) || type.replace(/^session_/, "");
@@ -2809,6 +2844,43 @@ async function emitAgentKilledEvent(sessionId, agentId, {
2809
2844
  return event;
2810
2845
  }
2811
2846
 
2847
+ function collectOption(value, previous) {
2848
+ return [...(previous || []), value];
2849
+ }
2850
+
2851
+ /**
2852
+ * Wrap a command action so it runs as its agent: inside that agent's admission when
2853
+ * this machine holds one (every session API call then uses the admission credential
2854
+ * and never the human token), on the legacy path when it holds none, and refused when
2855
+ * what it holds is expired or unusable. `resolveIds` names the (session, agent) pair
2856
+ * exactly the way the action itself resolves it.
2857
+ */
2858
+ function asAdmittedAgent(resolveIds, action) {
2859
+ return async (...args) => {
2860
+ const { sessionId, agentId } = await resolveIds(...args);
2861
+ return withAgentAdmission(sessionId, agentId, () => action(...args));
2862
+ };
2863
+ }
2864
+
2865
+ async function sessionSayAgent(sessionId, _messageParts, options) {
2866
+ const sid = normalizeString(sessionId);
2867
+ const identity = await resolveSessionSayIdentity({
2868
+ sessionId: sid,
2869
+ agentId: options.agent,
2870
+ targetPath: path.resolve(process.cwd(), String(options.path || ".")),
2871
+ });
2872
+ return { sessionId: sid, agentId: identity.agentId };
2873
+ }
2874
+
2875
+ async function sessionReadAgent(sessionId, options) {
2876
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2877
+ return { sessionId: normalizeString(sessionId), agentId: await defaultAgentId(options.agent, targetPath) };
2878
+ }
2879
+
2880
+ async function sessionListenAgent(options) {
2881
+ return { sessionId: resolveSessionIdOption(options), agentId: normalizeAgentId(options.agent, "cli-user") };
2882
+ }
2883
+
2812
2884
  export function registerSessionCommand(program) {
2813
2885
  const session = program
2814
2886
  .command("session")
@@ -2923,8 +2995,9 @@ export function registerSessionCommand(program) {
2923
2995
  };
2924
2996
 
2925
2997
  // Best-effort admin visibility sync. Session creation remains local-first.
2998
+ let metadataSync = Promise.resolve(null);
2926
2999
  if (remoteSync.attempted) {
2927
- void syncSessionMetadataToApi(created.sessionId, {
3000
+ metadataSync = syncSessionMetadataToApi(created.sessionId, {
2928
3001
  targetPath,
2929
3002
  sessionId: created.sessionId,
2930
3003
  status: created.status,
@@ -2934,7 +3007,7 @@ export function registerSessionCommand(program) {
2934
3007
  ttlSeconds,
2935
3008
  template: created.template,
2936
3009
  codebaseContext: created.codebaseContext,
2937
- }).catch(() => {});
3010
+ }, { targetPath, timeoutMs: 2_000 }).catch(() => null);
2938
3011
  }
2939
3012
 
2940
3013
  // Make the session managed by default: spawn the Senti daemon as a
@@ -2971,6 +3044,14 @@ export function registerSessionCommand(program) {
2971
3044
  }).catch((error) => ({ posted: false, reason: normalizeString(error?.message) || "error" }));
2972
3045
  }
2973
3046
  payload.firstMessage = firstMessage;
3047
+ // Own the request lifecycle; do not leave a referenced fetch running
3048
+ // after emitting the single JSON document (even with --no-daemon).
3049
+ const metadataResult = await metadataSync;
3050
+ if (remoteSync.attempted) {
3051
+ remoteSync.status = metadataResult?.synced ? "synced" : "degraded";
3052
+ remoteSync.metadataSynced = Boolean(metadataResult?.synced);
3053
+ remoteSync.reason = metadataResult?.synced ? "metadata_acknowledged" : (metadataResult?.reason || "metadata_sync_failed");
3054
+ }
2974
3055
 
2975
3056
  if (shouldEmitJson(options, command)) {
2976
3057
  console.log(JSON.stringify(payload, null, 2));
@@ -3319,6 +3400,21 @@ export function registerSessionCommand(program) {
3319
3400
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
3320
3401
  .option("--model <model>", "Agent model hint", "cli")
3321
3402
  .option("--path <path>", "Workspace path for the session", ".")
3403
+ .option(
3404
+ "--goal <text>",
3405
+ "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.",
3406
+ )
3407
+ .option("--deliverable <text>", "An output this agent will produce (repeatable)", collectOption, [])
3408
+ .option("--stop-when <text>", "A condition that ends this agent's work (repeatable)", collectOption, [])
3409
+ .option(
3410
+ "--scope <actions>",
3411
+ "Comma-separated actions to request: session.read, session.post, session.react, tickets.read, tickets.work",
3412
+ )
3413
+ .option("--ttl <duration>", "How long the grant should last, e.g. 90m or 2h (5m..24h)", "2h")
3414
+ .option("--provider <name>", "Model provider hint for the admission request")
3415
+ .option("--no-wait", "Request admission and return immediately with the approval URL")
3416
+ .option("--wait-seconds <n>", "How long to wait for approval before returning pending", "900")
3417
+ .option("--cancel-admission", "Cancel this workspace's pending admission request for the session")
3322
3418
  .option("--json", "Emit machine-readable output")
3323
3419
  .action(async (sessionId, options, command) => {
3324
3420
  const normalizedSessionId = normalizeString(sessionId);
@@ -3329,6 +3425,34 @@ export function registerSessionCommand(program) {
3329
3425
  const explicitAgent = normalizeString(options.agent);
3330
3426
  const legacyName = normalizeString(options.name);
3331
3427
  const inviteToken = normalizeString(options.inviteToken);
3428
+ const emitJson = shouldEmitJson(options, command);
3429
+ // ONE requested identity for the whole join, validated before any side effect.
3430
+ // --name is the identity only when --agent is absent (its legacy alias); beside an
3431
+ // explicit --agent it is a free-text display label. Either way the identity is
3432
+ // used in its canonical spelling from here on, so the admission, its receipt,
3433
+ // the local registration and every later implicit command name the same agent.
3434
+ if (!explicitAgent) assertCanonicalAgentId(legacyName, "--name");
3435
+ const requestedAgent = canonicalAgentId(explicitAgent || legacyName);
3436
+ if (options.cancelAdmission) {
3437
+ const cancelled = await cancelAdmission(normalizedSessionId, {
3438
+ agentId: requestedAgent,
3439
+ targetPath,
3440
+ });
3441
+ const payload = {
3442
+ command: "session join",
3443
+ joined: false,
3444
+ sessionId: normalizedSessionId,
3445
+ admission: {
3446
+ status: cancelled?.status || "cancelled",
3447
+ admissionId: cancelled?.admissionId || null,
3448
+ },
3449
+ };
3450
+ if (emitJson) console.log(JSON.stringify(payload, null, 2));
3451
+ else console.log(`Admission request ${payload.admission.admissionId || ""} cancelled.`);
3452
+ return;
3453
+ }
3454
+ // Membership first. Only a member may ask for an agent to be admitted, so an
3455
+ // invitation is accepted BEFORE the admission request, never after.
3332
3456
  let invitationAccept = null;
3333
3457
  let invitationAcceptResult = null;
3334
3458
  if (inviteToken) {
@@ -3336,7 +3460,7 @@ export function registerSessionCommand(program) {
3336
3460
  targetPath,
3337
3461
  invitationToken: inviteToken,
3338
3462
  seatKey: options.seatKey,
3339
- agentId: explicitAgent || legacyName,
3463
+ agentId: requestedAgent,
3340
3464
  idempotencyKey: options.idempotencyKey,
3341
3465
  });
3342
3466
  invitationAcceptResult =
@@ -3344,6 +3468,55 @@ export function registerSessionCommand(program) {
3344
3468
  ? invitationAccept.result
3345
3469
  : null;
3346
3470
  }
3471
+ let admission = null;
3472
+ if (normalizeString(options.goal)) {
3473
+ const admissionAgent = requestedAgent;
3474
+ if (!admissionAgent) {
3475
+ throw new Error("--goal requires --agent <id>: admission is requested for one named agent.");
3476
+ }
3477
+ const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
3478
+ admission = await runAdmissionJoin(normalizedSessionId, {
3479
+ agentId: admissionAgent,
3480
+ displayName: legacyName && explicitAgent ? legacyName : "",
3481
+ model: options.model,
3482
+ provider: options.provider,
3483
+ goal: options.goal,
3484
+ deliverables: options.deliverable || [],
3485
+ stopConditions: options.stopWhen || [],
3486
+ actions: parseAdmissionScope(options.scope),
3487
+ ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
3488
+ wait: options.wait !== false,
3489
+ waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
3490
+ targetPath,
3491
+ onPending: ({ approveUrl }) => {
3492
+ // stderr, so --json stdout stays a single parseable document.
3493
+ const line = `Waiting for a room owner to approve this agent: ${approveUrl}`;
3494
+ if (emitJson) process.stderr.write(`${line}\n`);
3495
+ else console.log(pc.yellow(line));
3496
+ },
3497
+ });
3498
+ if (admission.status !== "active") {
3499
+ // Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
3500
+ // registers the agent or materializes the room for it.
3501
+ process.exitCode = admission.status === "pending" ? 3 : 4;
3502
+ const payload = {
3503
+ command: "session join",
3504
+ joined: false,
3505
+ sessionId: normalizedSessionId,
3506
+ agentId: admissionAgent,
3507
+ invitationAccepted: Boolean(invitationAcceptResult),
3508
+ admission,
3509
+ };
3510
+ if (emitJson) {
3511
+ console.log(JSON.stringify(payload, null, 2));
3512
+ } else if (admission.status === "pending") {
3513
+ console.log(pc.yellow(`Admission pending. A room owner approves it here: ${admission.approveUrl || "(see the session page)"}`));
3514
+ } else {
3515
+ console.log(pc.red(`Admission ${admission.status}. The agent was not joined.`));
3516
+ }
3517
+ return;
3518
+ }
3519
+ }
3347
3520
 
3348
3521
  // PR #483 contract: verify the session exists and the caller has access
3349
3522
  // BEFORE materializing local cache state. Without this we'd silently
@@ -3355,7 +3528,48 @@ export function registerSessionCommand(program) {
3355
3528
  // before the invite is accepted the user may legitimately receive 403,
3356
3529
  // so --invite-token performs the guarded accept mutation first and this
3357
3530
  // verification proves membership immediately afterward.
3358
- const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
3531
+ // An admitted agent proves its grant is live with ITS OWN credential, via the
3532
+ // grant's receipt, and never with the human token. A cached "active" is not
3533
+ // reported until this succeeds (a revoked grant is not joined), and nothing
3534
+ // about the room is fetched on the human's authority.
3535
+ const admittedAgentId = admission ? requestedAgent : "";
3536
+ let admissionReceipt = null;
3537
+ if (admission) {
3538
+ try {
3539
+ admissionReceipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3540
+ fetchOwnAdmissionReceipt(normalizedSessionId, { targetPath }),
3541
+ );
3542
+ // The grant must be for EXACTLY the identity this join registers, byte for
3543
+ // byte: a receipt for any other spelling is a different agent's authority.
3544
+ const receiptAgent = String(admissionReceipt?.agentId ?? "");
3545
+ if (receiptAgent !== admittedAgentId) {
3546
+ throw new Error(
3547
+ `the admission is for agent "${receiptAgent || "(none)"}", not "${admittedAgentId}"`,
3548
+ );
3549
+ }
3550
+ } catch (error) {
3551
+ process.exitCode = 4;
3552
+ const payload = {
3553
+ command: "session join",
3554
+ joined: false,
3555
+ sessionId: normalizedSessionId,
3556
+ agentId: admittedAgentId,
3557
+ admission: {
3558
+ ...admission,
3559
+ status: "not_accepted",
3560
+ verified: false,
3561
+ reason: error instanceof Error ? error.message : String(error),
3562
+ },
3563
+ };
3564
+ if (emitJson) console.log(JSON.stringify(payload, null, 2));
3565
+ else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined.`));
3566
+ return;
3567
+ }
3568
+ admission = { ...admission, verified: true, receipt: admissionReceipt };
3569
+ }
3570
+ const verification = admission
3571
+ ? { ok: true, source: "admission_receipt", session: { sessionId: normalizedSessionId } }
3572
+ : await verifyRemoteSession(normalizedSessionId, { targetPath });
3359
3573
  if (!verification.ok) {
3360
3574
  if (verification.status === 404 || verification.reason === "not_found") {
3361
3575
  throw new Error(
@@ -3382,17 +3596,31 @@ export function registerSessionCommand(program) {
3382
3596
  skipRemoteProbe: true,
3383
3597
  remoteSession,
3384
3598
  });
3385
- const joinHydration = await hydrateJoinBriefingContext(normalizedSessionId, {
3386
- targetPath,
3387
- });
3599
+ // History only for a grant that may read it, and then on the admission.
3600
+ const joinHydration = !admission
3601
+ ? await hydrateJoinBriefingContext(normalizedSessionId, { targetPath })
3602
+ : (admissionReceipt?.actions || []).includes("session.read")
3603
+ ? await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3604
+ hydrateJoinBriefingContext(normalizedSessionId, { targetPath }),
3605
+ )
3606
+ : { skipped: true, reason: "the grant does not include session.read" };
3388
3607
 
3389
3608
  const acceptedOnboarding = normalizeSessionOnboarding(
3390
3609
  invitationAcceptResult?.onboarding,
3391
3610
  invitationAcceptResult?.claimedSeat,
3392
3611
  );
3393
3612
  const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
3394
- const agentSeed = explicitAgent || acceptedAgentId || legacyName;
3613
+ // An admitted join registers the admitted identity and nothing else. Otherwise an
3614
+ // invitation's onboarding agent id is the identity only when none was given, and
3615
+ // it is held to the same canonical rule before anything is registered locally.
3616
+ if (!admission && !explicitAgent && acceptedAgentId) {
3617
+ assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
3618
+ }
3619
+ const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
3395
3620
  const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
3621
+ if (admission && resolvedAgentId !== admittedAgentId) {
3622
+ throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
3623
+ }
3396
3624
  const roleWasExplicit = optionWasSetByCli(command, "role");
3397
3625
  const role =
3398
3626
  normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
@@ -3485,6 +3713,7 @@ export function registerSessionCommand(program) {
3485
3713
  jsonPath: onboardingBrief.jsonPath,
3486
3714
  }
3487
3715
  : null,
3716
+ admission,
3488
3717
  };
3489
3718
  if (shouldEmitJson(options, command)) {
3490
3719
  console.log(JSON.stringify(payload, null, 2));
@@ -3505,6 +3734,23 @@ export function registerSessionCommand(program) {
3505
3734
  console.log(pc.green(`Invitation accepted${seatLabel}`));
3506
3735
  }
3507
3736
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
3737
+ if (admission) {
3738
+ if (admission.status === "active") {
3739
+ const expires = new Date(admission.grant.expiresAt * 1000).toISOString();
3740
+ console.log(
3741
+ pc.green(
3742
+ `Admitted${admission.reused ? " (existing grant)" : ""}: ${admission.grant.actions.join(", ")} until ${expires}`,
3743
+ ),
3744
+ );
3745
+ if (admission.identity?.subject) console.log(pc.gray(`identity=${admission.identity.subject}`));
3746
+ console.log(pc.gray(`credential stored at ${admission.credential.storage} (never printed)`));
3747
+ } else if (admission.status === "pending") {
3748
+ console.log(pc.yellow(`Admission pending approval: ${admission.approveUrl}`));
3749
+ console.log(pc.gray("Re-run the same command to resume waiting."));
3750
+ } else {
3751
+ console.log(pc.red(`Admission ${admission.status}.`));
3752
+ }
3753
+ }
3508
3754
  if (payload.onboardingGuide?.markdownPath) {
3509
3755
  console.log(pc.gray(`onboarding=${payload.onboardingGuide.markdownPath}`));
3510
3756
  }
@@ -3544,7 +3790,7 @@ export function registerSessionCommand(program) {
3544
3790
  .option("--local-only", "Append only to the local session cache without remote send confirmation")
3545
3791
  .option("--path <path>", "Workspace path for the session", ".")
3546
3792
  .option("--json", "Emit machine-readable output")
3547
- .action(async (sessionId, messageParts, options, command) => {
3793
+ .action(asAdmittedAgent(sessionSayAgent, async (sessionId, messageParts, options, command) => {
3548
3794
  const normalizedSessionId = normalizeString(sessionId);
3549
3795
  if (!normalizedSessionId) {
3550
3796
  throw new Error("session id is required.");
@@ -3672,7 +3918,7 @@ export function registerSessionCommand(program) {
3672
3918
  console.error(pc.yellow(`Identity warning: ${identity.identityWarning}`));
3673
3919
  }
3674
3920
  console.log(formatEventLine(persisted));
3675
- });
3921
+ }));
3676
3922
 
3677
3923
  session
3678
3924
  .command("post-agent <sessionId> <message>")
@@ -3962,7 +4208,20 @@ export function registerSessionCommand(program) {
3962
4208
  console.log(formatEventLine(persisted));
3963
4209
  });
3964
4210
 
3965
- async function runMessageActionCommand({
4211
+ // Every message-action command (react, reply, ack, ...) runs as its authoring
4212
+ // agent: inside that agent's admission when this machine holds one.
4213
+ async function runMessageActionCommand(args = {}) {
4214
+ const sid = normalizeString(args.sessionId);
4215
+ const identity = await resolveMessageActionIdentity({
4216
+ sessionId: sid,
4217
+ optionAgent: args.options?.agent,
4218
+ targetPath: path.resolve(process.cwd(), String(args.options?.path || ".")),
4219
+ env: process.env,
4220
+ });
4221
+ return withAgentAdmission(sid, identity.agentId, () => runMessageActionCommandUnscoped(args));
4222
+ }
4223
+
4224
+ async function runMessageActionCommandUnscoped({
3966
4225
  sessionId,
3967
4226
  actionType,
3968
4227
  options,
@@ -4193,20 +4452,50 @@ export function registerSessionCommand(program) {
4193
4452
  });
4194
4453
 
4195
4454
  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")
4455
+ .command("edit <sessionId> <target> <message...>")
4456
+ .description("Edit your message by sequence or threaded reply UUID; preserves its audit history")
4457
+ .option("--agent <id>", "Authoring agent (defaults to the joined session agent)")
4458
+ .option("--expected-revision <n>", "Guard against concurrent edits; otherwise fetch the current revision once")
4459
+ .option("--idempotency-key <key>", "Reuse an explicit key when retrying the same edit")
4200
4460
  .option("--path <path>", "Workspace path for the session", ".")
4201
4461
  .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),
4462
+ .action(async (sessionId, target, messageParts, options, command) => {
4463
+ const sid = normalizeString(sessionId);
4464
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4465
+ const text = Array.isArray(messageParts) ? messageParts.join(" ") : String(messageParts || "");
4466
+ const sequence = /^\d+$/.test(target) ? Number(target) : null;
4467
+ if (sequence !== null && (!Number.isSafeInteger(sequence) || sequence <= 0)) throw new Error("target sequence must be a positive safe integer.");
4468
+ 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)) {
4469
+ throw new Error("Edit target must be a positive message sequence or a threaded reply UUID.");
4470
+ }
4471
+ const identity = await resolveMessageActionIdentity({ sessionId: sid, optionAgent: options.agent, targetPath, env: process.env });
4472
+ if (shouldBlockImplicitCliUserSessionSay(identity)) throw new Error(identity.identityWarning || "session edit requires an authoring agent identity; pass --agent <id>.");
4473
+ const expectedRevision = options.expectedRevision === undefined ? null : Number(options.expectedRevision);
4474
+ if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision <= 0)) throw new Error("expected-revision must be a positive safe integer.");
4475
+ return withAgentAdmission(sid, identity.agentId, async () => {
4476
+ const result = await editSessionMessage(sid, {
4477
+ targetPath,
4478
+ targetSequenceId: sequence,
4479
+ targetActionId: sequence ? "" : target,
4480
+ text,
4481
+ agentId: identity.agentId,
4482
+ expectedRevision,
4483
+ idempotencyKey: options.idempotencyKey,
4484
+ timeoutMs: 15_000,
4485
+ });
4486
+ if (!result.ok) throw new Error(`Session edit failed (${result.reason || "unknown"}). No local message was changed.`);
4487
+ let localAppend = { appended: 0, upgraded: 0, failed: 0 };
4488
+ try {
4489
+ await ensureLocalSessionForRemoteCommand(sid, { targetPath });
4490
+ const current = result.reply ? replyRevisionEvent(sid, result.reply) : result.event;
4491
+ localAppend = await appendMissingRemoteEvents(sid, [current, result.notification].filter(Boolean), { targetPath });
4492
+ } catch {
4493
+ localAppend = { appended: 0, upgraded: 0, failed: 1 };
4494
+ }
4495
+ const payload = { command: "session edit", sessionId: sid, target, ...result, localAppend, localAppendComplete: localAppend.failed === 0 };
4496
+ if (shouldEmitJson(options, command)) console.log(JSON.stringify(payload, null, 2));
4497
+ 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." : ""}`);
4498
+ return payload;
4210
4499
  });
4211
4500
  });
4212
4501
 
@@ -4951,7 +5240,7 @@ export function registerSessionCommand(program) {
4951
5240
  `Total log files to retain including the active file (default ${DEFAULT_ROTATING_LOG_MAX_FILES})`,
4952
5241
  String(DEFAULT_ROTATING_LOG_MAX_FILES),
4953
5242
  )
4954
- .action(async (options) => {
5243
+ .action(asAdmittedAgent(sessionListenAgent, async (options) => {
4955
5244
  const normalizedSessionId = resolveSessionIdOption(options);
4956
5245
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4957
5246
  const agentId = normalizeAgentId(options.agent, "cli-user");
@@ -5043,9 +5332,15 @@ export function registerSessionCommand(program) {
5043
5332
  options.maxPolls === undefined
5044
5333
  ? null
5045
5334
  : parsePositiveInteger(options.maxPolls, "max-polls", 1);
5046
- const listenTransport = requestedTransport === "auto" && maxPolls !== null
5047
- ? "poll"
5048
- : requestedTransport;
5335
+ if (currentAdmittedAgent() && requestedTransport === "stream") {
5336
+ throw new Error(
5337
+ "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.",
5338
+ );
5339
+ }
5340
+ const listenTransport =
5341
+ requestedTransport === "auto" && (maxPolls !== null || currentAdmittedAgent())
5342
+ ? "poll"
5343
+ : requestedTransport;
5049
5344
  const since = options.since === undefined ? undefined : String(options.since);
5050
5345
  if (options.fromNow && options.since !== undefined) {
5051
5346
  throw new Error("Use either --from-now or --since, not both.");
@@ -5367,7 +5662,7 @@ export function registerSessionCommand(program) {
5367
5662
  }).catch(() => {});
5368
5663
  }
5369
5664
  }
5370
- });
5665
+ }));
5371
5666
 
5372
5667
  session
5373
5668
  .command("daemon [sessionId]")
@@ -5939,7 +6234,7 @@ export function registerSessionCommand(program) {
5939
6234
  .option("--include-control-events", "Include listener lifecycle/control-plane events in transcript output")
5940
6235
  .option("--path <path>", "Workspace path for the session", ".")
5941
6236
  .option("--json", "Emit machine-readable output")
5942
- .action(async (sessionId, options, command) => {
6237
+ .action(asAdmittedAgent(sessionReadAgent, async (sessionId, options, command) => {
5943
6238
  const normalizedSessionId = normalizeString(sessionId);
5944
6239
  if (!normalizedSessionId) {
5945
6240
  throw new Error("session id is required.");
@@ -6283,7 +6578,7 @@ export function registerSessionCommand(program) {
6283
6578
  console.log(formatEventLine(event));
6284
6579
  }
6285
6580
  }
6286
- });
6581
+ }));
6287
6582
 
6288
6583
  session
6289
6584
  .command("search <sessionId> <query>")
@@ -6492,6 +6787,147 @@ export function registerSessionCommand(program) {
6492
6787
  }
6493
6788
  });
6494
6789
 
6790
+ // ---------------------------------------------------------------- tickets
6791
+ // The room's shared backlog (API "Ticket Lease Contract (T2)"). Every subcommand is
6792
+ // an ACTOR command: the runCli choke point runs it on the agent's admission.
6793
+ const ticket = session
6794
+ .command("ticket")
6795
+ .description("The room's shared backlog: list, claim, renew, release and submit tickets");
6796
+
6797
+ // Ticket work is attributed by the CREDENTIAL, never by a label: the lease routes take the
6798
+ // holder from authentication alone. So an agent identity is allowed only with that agent's
6799
+ // live admission in scope; without it, the request would go out on the human token and
6800
+ // claim as the PERSON while this command reported the agent. Refused before any request.
6801
+ async function ticketIdentity(sessionId, options, targetPath) {
6802
+ const identity = await resolveSessionSayIdentity({ sessionId, agentId: options.agent, targetPath });
6803
+ const agent = normalizeString(identity.agentId);
6804
+ if (!agent || agent === "cli-user") return "human";
6805
+ const admitted = currentAdmittedAgent();
6806
+ if (!admitted || admitted.sessionId !== sessionId || admitted.agentId !== agent) {
6807
+ throw new Error(
6808
+ `Agent "${agent}" has no live admission in this room. Ticket work is attributed by the credential, so ` +
6809
+ `without one this would act as you, not as "${agent}". Admit it first: ` +
6810
+ `sl session join ${sessionId} --agent ${agent} --goal "<what it is here to do>"`
6811
+ );
6812
+ }
6813
+ return agent;
6814
+ }
6815
+
6816
+ function ticketOutput(options, command, payload, line) {
6817
+ if (shouldEmitJson(options, command)) {
6818
+ console.log(JSON.stringify(payload, null, 2));
6819
+ return;
6820
+ }
6821
+ console.log(line);
6822
+ }
6823
+
6824
+ ticket
6825
+ .command("list <sessionId>")
6826
+ .description("List the room's tickets (--available: only those claimable now)")
6827
+ .option("--available", "Only tickets that can be claimed now (never blocked work)")
6828
+ .option("--agent <id>", "Agent id reading the backlog (defaults to the joined session agent)")
6829
+ .option("--path <path>", "Workspace path for the session", ".")
6830
+ .option("--json", "Emit machine-readable output")
6831
+ .action(async (sessionId, options, command) => {
6832
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6833
+ const result = await listTickets(normalizeString(sessionId), { targetPath, available: Boolean(options.available) });
6834
+ const lines = result.items.map((t) => {
6835
+ const holder = t.lease ? ` · ${t.lease.live ? "leased by" : "lease EXPIRED, was"} ${t.lease.holder}` : "";
6836
+ const done = t.completion && !t.completion.verified ? " (manual, unverified)" : "";
6837
+ return `${t.id} ${t.status}${done} v${t.version} ${t.title}${holder}`;
6838
+ });
6839
+ ticketOutput(options, command, { command: "session ticket list", ...result }, lines.join("\n") || "No tickets.");
6840
+ });
6841
+
6842
+ ticket
6843
+ .command("claim <sessionId> <ticketId>")
6844
+ .description("Claim a ticket: take its fenced lease")
6845
+ .option("--expected-version <n>", "Refuse if the ticket has changed since this version")
6846
+ .option("--agent <id>", "Agent id claiming the ticket (defaults to the joined session agent)")
6847
+ .option("--path <path>", "Workspace path for the session", ".")
6848
+ .option("--json", "Emit machine-readable output")
6849
+ .action(async (sessionId, ticketId, options, command) => {
6850
+ const sid = normalizeString(sessionId);
6851
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6852
+ const identity = await ticketIdentity(sid, options, targetPath);
6853
+ const expectedVersion =
6854
+ options.expectedVersion === undefined ? null : parsePositiveInteger(options.expectedVersion, "expected-version", 1);
6855
+ const result = await claimTicket(sid, normalizeString(ticketId), { identity, expectedVersion, targetPath });
6856
+ ticketOutput(options, command, { command: "session ticket claim", identity, ...result },
6857
+ `Claimed ${ticketId} (fence ${result.lease.fence}, until ${result.lease.expiresAt}).`);
6858
+ });
6859
+
6860
+ ticket
6861
+ .command("renew <sessionId> <ticketId>")
6862
+ .description("Renew the lease this identity holds on a ticket")
6863
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
6864
+ .option("--path <path>", "Workspace path for the session", ".")
6865
+ .option("--json", "Emit machine-readable output")
6866
+ .action(async (sessionId, ticketId, options, command) => {
6867
+ const sid = normalizeString(sessionId);
6868
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6869
+ const identity = await ticketIdentity(sid, options, targetPath);
6870
+ const result = await renewTicketLease(sid, normalizeString(ticketId), { identity, targetPath });
6871
+ ticketOutput(options, command, { command: "session ticket renew", identity, ...result },
6872
+ `Renewed ${ticketId} until ${result.lease.expiresAt}.`);
6873
+ });
6874
+
6875
+ ticket
6876
+ .command("release <sessionId> <ticketId>")
6877
+ .description("Give a ticket back: it returns to open for the next claimant")
6878
+ .option("--reason <text>", "Why it is being released")
6879
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
6880
+ .option("--path <path>", "Workspace path for the session", ".")
6881
+ .option("--json", "Emit machine-readable output")
6882
+ .action(async (sessionId, ticketId, options, command) => {
6883
+ const sid = normalizeString(sessionId);
6884
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6885
+ const identity = await ticketIdentity(sid, options, targetPath);
6886
+ const result = await releaseTicketLease(sid, normalizeString(ticketId), {
6887
+ identity, targetPath, reason: normalizeString(options.reason) || null,
6888
+ });
6889
+ ticketOutput(options, command, { command: "session ticket release", identity, ...result }, `Released ${ticketId}.`);
6890
+ });
6891
+
6892
+ ticket
6893
+ .command("submit <sessionId> <ticketId>")
6894
+ .description("Submit the work for review (in_review). Completion needs verification; this is not done.")
6895
+ .option("--sha <sha>", "The implementation commit")
6896
+ .option("--evidence-file <path>", "JSON array of re-checkable evidence claims")
6897
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
6898
+ .option("--path <path>", "Workspace path for the session", ".")
6899
+ .option("--json", "Emit machine-readable output")
6900
+ .action(async (sessionId, ticketId, options, command) => {
6901
+ const sid = normalizeString(sessionId);
6902
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6903
+ const identity = await ticketIdentity(sid, options, targetPath);
6904
+ let evidence = [];
6905
+ if (options.evidenceFile) {
6906
+ evidence = JSON.parse(await fsp.readFile(path.resolve(targetPath, String(options.evidenceFile)), "utf8"));
6907
+ if (!Array.isArray(evidence)) throw new Error("--evidence-file must contain a JSON array.");
6908
+ }
6909
+ const result = await submitTicket(sid, normalizeString(ticketId), {
6910
+ identity, targetPath, implementationSha: normalizeString(options.sha) || null, evidence,
6911
+ });
6912
+ ticketOutput(options, command, { command: "session ticket submit", identity, ...result },
6913
+ `Submitted ${ticketId} for review.`);
6914
+ });
6915
+
6916
+ ticket
6917
+ .command("events <sessionId>")
6918
+ .description("The room's ticket events after a cursor, in commit order")
6919
+ .option("--after <cursor>", "Only events after this cursor", "0")
6920
+ .option("--agent <id>", "Agent id reading the feed (defaults to the joined session agent)")
6921
+ .option("--path <path>", "Workspace path for the session", ".")
6922
+ .option("--json", "Emit machine-readable output")
6923
+ .action(async (sessionId, options, command) => {
6924
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6925
+ const after = Number.parseInt(String(options.after ?? "0"), 10) || 0;
6926
+ const result = await ticketEvents(normalizeString(sessionId), { after, targetPath });
6927
+ const lines = (result.events || []).map((e) => `#${e.cursor} ${e.kind} ${e.ticketId} v${e.ticketVersion} by ${e.actorId}`);
6928
+ ticketOutput(options, command, { command: "session ticket events", ...result }, lines.join("\n") || "No new ticket events.");
6929
+ });
6930
+
6495
6931
  const checkpoint = session
6496
6932
  .command("checkpoint")
6497
6933
  .description("List, show, create, and generate durable session checkpoints");