sentinelayer-cli 0.43.0 → 0.45.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 (48) hide show
  1. package/README.md +29 -16
  2. package/package.json +1 -1
  3. package/src/agents/jules/tools/aidenid-email.js +7 -9
  4. package/src/agents/jules/tools/frontend-analyze.js +15 -3
  5. package/src/agents/jules/tools/runtime-audit.js +51 -21
  6. package/src/ai/aidenid.js +5 -4
  7. package/src/ai/proxy.js +15 -14
  8. package/src/audit/orchestrator.js +8 -5
  9. package/src/auth/admission-scope.js +5 -3
  10. package/src/auth/credential-destinations.js +221 -0
  11. package/src/auth/gate.js +4 -4
  12. package/src/auth/http.js +22 -5
  13. package/src/auth/service.js +52 -56
  14. package/src/cli.js +14 -6
  15. package/src/commands/ai/provision-governance.js +7 -6
  16. package/src/commands/init.js +25 -16
  17. package/src/commands/scan.js +6 -4
  18. package/src/commands/session.js +166 -51
  19. package/src/commands/spec.js +2 -2
  20. package/src/commands/watch.js +4 -3
  21. package/src/config/service.js +37 -0
  22. package/src/daemon/pulse.js +2 -1
  23. package/src/daemon/watchdog.js +117 -68
  24. package/src/legacy-cli.js +119 -39
  25. package/src/mcp/bridge-inputs.js +223 -0
  26. package/src/mcp/cli-command-tools.js +160 -7
  27. package/src/mcp/registry.js +6 -5
  28. package/src/mcp/session-stdio-server.js +157 -8
  29. package/src/mcp/smoke.js +27 -11
  30. package/src/mcp/token-service.js +7 -9
  31. package/src/memory/retrieval.js +14 -5
  32. package/src/pocket/ring-owner.js +13 -8
  33. package/src/review/ai-review.js +2 -2
  34. package/src/review/dd-report-email-client.js +4 -5
  35. package/src/session/admission-access.js +41 -10
  36. package/src/session/admission-auth.js +95 -16
  37. package/src/session/admission.js +91 -21
  38. package/src/session/checkpoints.js +11 -10
  39. package/src/session/control-events.js +2 -0
  40. package/src/session/file-locks.js +19 -18
  41. package/src/session/invitations.js +26 -19
  42. package/src/session/live-source.js +13 -13
  43. package/src/session/reactions.js +258 -0
  44. package/src/session/route-classes.js +107 -0
  45. package/src/session/sync.js +110 -66
  46. package/src/session/tickets.js +21 -19
  47. package/src/session/title-sync.js +4 -3
  48. package/src/telemetry/sync.js +8 -9
package/src/cli.js CHANGED
@@ -242,6 +242,11 @@ export async function buildCliProgram({
242
242
  }
243
243
 
244
244
  export async function runCli(rawArgs = process.argv.slice(2)) {
245
+ // Where the user's credentials may go is decided once, here, from the real environment and the
246
+ // global config, and frozen: nothing that changes process.env later can move it.
247
+ const { freezeProcessTrustContext } = await import("./auth/credential-destinations.js");
248
+ await freezeProcessTrustContext();
249
+
245
250
  // Normalize slash commands (/omargate → omargate, /audit → audit, etc.)
246
251
  const normalizedArgs = normalizeSlashArgs(rawArgs);
247
252
 
@@ -269,15 +274,18 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
269
274
  // the subcommand (say, post-agent, observe, locks, checkpoints, ...), so no
270
275
  // subcommand, present or future, can reach the human token. A tombstoned
271
276
  // admission refuses here, before any request.
272
- const { assertDispatchMatchesScope, resolveAgentAdmissionTarget, withAgentAdmission } = await import(
273
- "./session/admission-auth.js"
274
- );
277
+ const { assertDispatchMatchesScope, resolveAgentAdmissionTarget, withAgentAdmission } =
278
+ await import("./session/admission-auth.js");
275
279
  const target = await resolveAgentAdmissionTarget(normalizedArgs);
276
- // Execution must match authorization: before ANY action runs, re-derive the identity
277
- // from Commander's resolved options and compare it with the scope in force.
280
+ // Execution must match authorization: before ANY action runs, decide on Commander's parsed
281
+ // arguments and options -- the values the action will use. Through the MCP CLI bridge in an
282
+ // agent context the action binds to its session's admission, runs a legacy data-plane route
283
+ // as before, or is refused (src/session/route-classes.js); and the identity it resolves must
284
+ // be the one whose scope is in force.
278
285
  program.hook("preAction", (_hooked, actionCommand) => assertDispatchMatchesScope(actionCommand));
279
286
  if (target) {
280
- await withAgentAdmission(target.sessionId, target.agentId, dispatch);
287
+ // An admission seen above that is gone by the time it is read refuses: never a fallback.
288
+ await withAgentAdmission(target.sessionId, target.agentId, dispatch, { requireStored: true });
281
289
  return;
282
290
  }
283
291
  await dispatch();
@@ -25,8 +25,7 @@ import {
25
25
  import { listSites, recordTemporarySite } from "../../ai/site-store.js";
26
26
  import { getIdentityById, recordProvisionedIdentity } from "../../ai/identity-store.js";
27
27
  import { resolveOutputRoot } from "../../config/service.js";
28
- import { readStoredSession } from "../../auth/session-store.js";
29
- import { fetchAidenIdCredentials } from "../../auth/service.js";
28
+ import { fetchAidenIdCredentials, resolveActiveAuthSession } from "../../auth/service.js";
30
29
  import {
31
30
  buildCurlPreview,
32
31
  normalizeIdempotencyKey,
@@ -37,6 +36,7 @@ import {
37
36
  stableTimestampForFile,
38
37
  writeArtifact,
39
38
  } from "./shared.js";
39
+ import { isAuthenticated } from "../../auth/credential-destinations.js";
40
40
 
41
41
  function addProvisionEmailOptions(cmd) {
42
42
  return cmd
@@ -92,12 +92,13 @@ async function provisionEmailAction(options, command) {
92
92
  payload,
93
93
  });
94
94
 
95
- let session = null;
96
- try { session = await readStoredSession(); } catch { /* no session */ }
95
+ const session = await resolveActiveAuthSession({ cwd: process.cwd(), env: process.env, autoRotate: false }).catch(
96
+ () => null,
97
+ );
97
98
 
98
99
  const makeFetcher = () => {
99
- if (!session || !session.token) return null;
100
- return () => fetchAidenIdCredentials({ apiUrl: session.apiUrl, token: session.token });
100
+ if (!isAuthenticated(session)) return null;
101
+ return () => fetchAidenIdCredentials({ apiUrl: session.apiUrl, auth: session });
101
102
  };
102
103
 
103
104
  const resolvedCredentials = await resolveAidenIdCredentials({
@@ -1,25 +1,26 @@
1
1
  import process from "node:process";
2
2
 
3
- import { loadConfig } from "../config/service.js";
3
+ import { loadConfig, userConfigValues } from "../config/service.js";
4
4
 
5
- function applyConfigEnvDefaults(resolvedConfig) {
6
- if (!process.env.SENTINELAYER_API_URL && resolvedConfig.apiUrl) {
7
- process.env.SENTINELAYER_API_URL = resolvedConfig.apiUrl;
8
- }
5
+ // Config values the scaffold flow reads from the environment. The API it talks to and the token
6
+ // it sends are never set here: they come from the trust context and the auth service
7
+ // (src/auth/credential-destinations.js), so a workspace .sentinelayer.yml cannot choose them,
8
+ // and child processes inherit neither.
9
+ // Provider keys come from the user's own config (global, or the environment), never the workspace.
10
+ function applyConfigEnvDefaults(config) {
11
+ const resolvedConfig = config.resolved;
12
+ const userValues = userConfigValues(config);
9
13
  if (!process.env.SENTINELAYER_WEB_URL && resolvedConfig.webUrl) {
10
- process.env.SENTINELAYER_WEB_URL = resolvedConfig.webUrl;
11
- }
12
- if (!process.env.SENTINELAYER_TOKEN && resolvedConfig.sentinelayerToken) {
13
- process.env.SENTINELAYER_TOKEN = resolvedConfig.sentinelayerToken;
14
+ process.env.SENTINELAYER_WEB_URL = resolvedConfig.webUrl; // printed as a link only
14
15
  }
15
- if (!process.env.OPENAI_API_KEY && resolvedConfig.openaiApiKey) {
16
- process.env.OPENAI_API_KEY = resolvedConfig.openaiApiKey;
16
+ if (!process.env.OPENAI_API_KEY && userValues.openaiApiKey) {
17
+ process.env.OPENAI_API_KEY = userValues.openaiApiKey;
17
18
  }
18
- if (!process.env.ANTHROPIC_API_KEY && resolvedConfig.anthropicApiKey) {
19
- process.env.ANTHROPIC_API_KEY = resolvedConfig.anthropicApiKey;
19
+ if (!process.env.ANTHROPIC_API_KEY && userValues.anthropicApiKey) {
20
+ process.env.ANTHROPIC_API_KEY = userValues.anthropicApiKey;
20
21
  }
21
- if (!process.env.GOOGLE_API_KEY && resolvedConfig.googleApiKey) {
22
- process.env.GOOGLE_API_KEY = resolvedConfig.googleApiKey;
22
+ if (!process.env.GOOGLE_API_KEY && userValues.googleApiKey) {
23
+ process.env.GOOGLE_API_KEY = userValues.googleApiKey;
23
24
  }
24
25
  }
25
26
 
@@ -30,9 +31,11 @@ export function registerInitCommand(program, invokeLegacy) {
30
31
  .option("--non-interactive", "Disable prompts and require interview payload")
31
32
  .option("--interview-file <path>", "Load interview JSON from file")
32
33
  .option("--skip-browser-open", "Do not auto-open browser during auth")
34
+ .option("--inject-secret", "Set the project token as a GitHub Actions secret on this directory's git remote")
35
+ .option("--inject-openai-key", "Also set OPENAI_API_KEY from your environment as a GitHub Actions secret there")
33
36
  .action(async (projectName, options) => {
34
37
  const config = await loadConfig();
35
- applyConfigEnvDefaults(config.resolved);
38
+ applyConfigEnvDefaults(config);
36
39
 
37
40
  const legacyArgs = [];
38
41
  const normalizedProjectName = String(projectName || "").trim();
@@ -49,6 +52,12 @@ export function registerInitCommand(program, invokeLegacy) {
49
52
  if (options.skipBrowserOpen) {
50
53
  legacyArgs.push("--skip-browser-open");
51
54
  }
55
+ if (options.injectSecret) {
56
+ legacyArgs.push("--inject-secret");
57
+ }
58
+ if (options.injectOpenaiKey) {
59
+ legacyArgs.push("--inject-openai-key");
60
+ }
52
61
 
53
62
  await invokeLegacy(legacyArgs);
54
63
  });
@@ -12,7 +12,7 @@ import {
12
12
  resolveProvider,
13
13
  } from "../ai/client.js";
14
14
  import { recordCliLlmSessionUsage, usageNumber } from "../billing/llm-session-usage.js";
15
- import { loadConfig, resolveOutputRoot } from "../config/service.js";
15
+ import { loadConfig, resolveOutputRoot, userConfigValues } from "../config/service.js";
16
16
  import { evaluateBudget } from "../cost/budget.js";
17
17
  import { appendCostEntry, summarizeCostHistory } from "../cost/history.js";
18
18
  import { estimateModelCost } from "../cost/tracker.js";
@@ -34,6 +34,7 @@ import { detectRepoSlug, setupSecrets } from "../scan/gh-secrets.js";
34
34
  import { appendRunEvent, deriveStopClassFromBudget } from "../telemetry/ledger.js";
35
35
  import { resolveActiveAuthSession } from "../auth/service.js";
36
36
  import { authLoginHint } from "../ui/command-hints.js";
37
+ import { exportCredentialToken, isAuthenticated } from "../auth/credential-destinations.js";
37
38
 
38
39
  const LEGACY_SCAN_WORKFLOW_PATH = ".github/workflows/security-review.yml";
39
40
 
@@ -578,7 +579,7 @@ export function registerScanCommand(program) {
578
579
  configModel: config.resolved.defaultModelId,
579
580
  });
580
581
  const explicitApiKey = String(options.apiKey || "").trim();
581
- const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, config.resolved);
582
+ const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, userConfigValues(config));
582
583
 
583
584
  const prompt = buildAiPreScanPrompt({
584
585
  targetPath,
@@ -856,8 +857,9 @@ export function registerScanCommand(program) {
856
857
  env: process.env,
857
858
  autoRotate: false,
858
859
  });
859
- if (session && session.token) {
860
- tokenValue = session.token;
860
+ if (isAuthenticated(session)) {
861
+ // the one reviewed export of the token: to a GitHub Actions secret, through gh
862
+ tokenValue = exportCredentialToken(session.credential, { purpose: "github-actions-secret" });
861
863
  }
862
864
  } catch {
863
865
  /* no active auth session */
@@ -7,6 +7,7 @@ import { spawn as defaultSpawn } from "node:child_process";
7
7
 
8
8
  import pc from "picocolors";
9
9
 
10
+ import { credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
10
11
  import { SentinelayerApiError, requestJsonMutation } from "../auth/http.js";
11
12
  import {
12
13
  buildProvisionEmailPayload,
@@ -15,7 +16,6 @@ import {
15
16
  resolveAidenIdCredentials,
16
17
  } from "../ai/aidenid.js";
17
18
  import { recordProvisionedIdentity } from "../ai/identity-store.js";
18
- import { readStoredSession } from "../auth/session-store.js";
19
19
  import { fetchAidenIdCredentials } from "../auth/service.js";
20
20
  import { resolveActiveAuthSession } from "../auth/service.js";
21
21
  import { resolveOutputRoot } from "../config/service.js";
@@ -111,6 +111,11 @@ import {
111
111
  currentAdmittedAgent,
112
112
  withAgentAdmission,
113
113
  } from "../session/admission-auth.js";
114
+ import {
115
+ SESSION_REACTION_TYPES,
116
+ isSessionReactionType,
117
+ submitSessionReaction,
118
+ } from "../session/reactions.js";
114
119
  import {
115
120
  claimTicket,
116
121
  listTickets,
@@ -564,10 +569,16 @@ const SESSION_MESSAGE_ACTION_TYPES = new Set([
564
569
  "reply",
565
570
  "like",
566
571
  "dislike",
572
+ "unlike",
573
+ "undislike",
567
574
  "disregard",
568
575
  "view",
569
576
  ]);
570
577
 
578
+ // like/dislike/unlike/undislike all go through src/session/reactions.js, the path the
579
+ // local MCP server shares; `ack` keeps the plain message-action path.
580
+ const SESSION_REACT_COMMAND_TYPES = Object.freeze(["ack", ...SESSION_REACTION_TYPES]);
581
+
571
582
  const SESSION_MESSAGE_ACTION_ALIASES = new Map([
572
583
  ["comment", "reply"],
573
584
  ]);
@@ -599,6 +610,16 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
599
610
  command: "sl session react <id> dislike --target-sequence <n>",
600
611
  description: "Negative lightweight feedback. Use --target-action-id <uuid> to react to a threaded reply.",
601
612
  },
613
+ {
614
+ type: "unlike",
615
+ command: "sl session react <id> unlike --target-sequence <n>",
616
+ description: "Retract your own active like. Refused when you have none; needs a server with reaction undo.",
617
+ },
618
+ {
619
+ type: "undislike",
620
+ command: "sl session react <id> undislike --target-sequence <n>",
621
+ description: "Retract your own active dislike. Refused when you have none; needs a server with reaction undo.",
622
+ },
602
623
  {
603
624
  type: "disregard",
604
625
  command: "sl session action <id> disregard --target-sequence <n>",
@@ -630,7 +651,7 @@ function shortSha256(value) {
630
651
 
631
652
  function actionEventType(actionType) {
632
653
  if (actionType === "reply") return "session_reply";
633
- if (actionType === "like" || actionType === "dislike") return "session_reaction";
654
+ if (isSessionReactionType(actionType)) return "session_reaction";
634
655
  return "session_action";
635
656
  }
636
657
 
@@ -686,6 +707,7 @@ function buildSessionActionEvent(sessionId, action = {}) {
686
707
  }),
687
708
  );
688
709
  const actorId = actionActorId(action);
710
+ const collapsedIntoActionId = normalizeString(action.collapsedIntoActionId ?? action.collapsed_into_action_id);
689
711
  const event = createAgentEvent({
690
712
  event: actionEventType(actionType),
691
713
  agent: {
@@ -710,6 +732,8 @@ function buildSessionActionEvent(sessionId, action = {}) {
710
732
  metadata: action.metadata && typeof action.metadata === "object" ? action.metadata : undefined,
711
733
  message: actionDisplayMessage(action),
712
734
  source: "session_action",
735
+ // A retained no-op reaction key (sentinelayer-api#914): evidence, not a reaction.
736
+ ...(collapsedIntoActionId ? { collapsedIntoActionId } : {}),
713
737
  },
714
738
  });
715
739
  event.eventId = `session-action-${id}`;
@@ -1298,19 +1322,11 @@ async function resolveSessionRemoteSyncState({ dashboardUrl } = {}) {
1298
1322
  };
1299
1323
  }
1300
1324
 
1301
- let storedSession = null;
1302
- try {
1303
- storedSession = await readStoredSession();
1304
- } catch {
1305
- storedSession = null;
1306
- }
1307
- const apiUrl =
1308
- normalizeString(storedSession?.apiUrl) ||
1309
- normalizeString(process.env.SENTINELAYER_API_URL) ||
1310
- "https://api.sentinelayer.com";
1311
- const hasToken = Boolean(
1312
- normalizeString(storedSession?.token) || normalizeString(process.env.SENTINELAYER_TOKEN),
1325
+ const auth = await resolveActiveAuthSession({ cwd: process.cwd(), env: process.env, autoRotate: false }).catch(
1326
+ () => null,
1313
1327
  );
1328
+ const apiUrl = normalizeString(auth?.apiUrl) || "https://api.sentinelayer.com";
1329
+ const hasToken = isAuthenticated(auth);
1314
1330
  if (!hasToken) {
1315
1331
  return {
1316
1332
  status: "auth_required",
@@ -1434,12 +1450,12 @@ async function findReusableSessionCandidate({
1434
1450
  // - When `SENTINELAYER_SKIP_REMOTE_SYNC=1` (test bootstrap), short-circuits
1435
1451
  // to `{ ok: true, source: "skipped", session: null }` so unit tests
1436
1452
  // can exercise the local materialization path without a real API.
1437
- async function fetchRemoteSessionDetail(endpoint, headers) {
1453
+ async function fetchRemoteSessionDetail(endpoint, credential) {
1438
1454
  let response;
1439
1455
  let payload;
1440
1456
  try {
1441
1457
  ({ response, payload } = await fetchJsonWithFullTimeout(
1442
- endpoint, { method: "GET", headers, redirect: "error" }, 2_000,
1458
+ endpoint, { method: "GET", credential, redirect: "error" }, 2_000,
1443
1459
  ));
1444
1460
  } catch (err) {
1445
1461
  return {
@@ -1492,7 +1508,7 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1492
1508
  } catch {
1493
1509
  return { ok: false, reason: "no_session" };
1494
1510
  }
1495
- if (!auth || !auth.token) {
1511
+ if (!isAuthenticated(auth)) {
1496
1512
  return { ok: false, reason: "not_authenticated", status: 401 };
1497
1513
  }
1498
1514
  const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
@@ -1500,10 +1516,10 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1500
1516
  return { ok: false, reason: "no_api_url" };
1501
1517
  }
1502
1518
  const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
1503
- const headers = { Authorization: `Bearer ${auth.token}` };
1504
- const firstAttempt = await fetchRemoteSessionDetail(endpoint, headers);
1519
+ const credential = await credentialFor(auth);
1520
+ const firstAttempt = await fetchRemoteSessionDetail(endpoint, credential);
1505
1521
  const detail = firstAttempt.retryable
1506
- ? await fetchRemoteSessionDetail(endpoint, headers)
1522
+ ? await fetchRemoteSessionDetail(endpoint, credential)
1507
1523
  : firstAttempt;
1508
1524
  if (detail.ok) {
1509
1525
  return {
@@ -2758,7 +2774,7 @@ async function resolveAdminApiSession({ targetPath, explicitApiUrl }) {
2758
2774
  explicitApiUrl,
2759
2775
  autoRotate: true,
2760
2776
  });
2761
- if (!session || !session.token) {
2777
+ if (!isAuthenticated(session)) {
2762
2778
  throw new Error(`No active auth token found. Run \`${authLoginHint()}\` first.`);
2763
2779
  }
2764
2780
  return session;
@@ -2778,10 +2794,8 @@ async function postAdminSessionMutation({
2778
2794
  return requestJsonMutation(`${apiUrl}${pathSuffix}`, {
2779
2795
  method: "POST",
2780
2796
  operationName,
2781
- headers: {
2782
- Authorization: `Bearer ${normalizeString(session.token)}`,
2783
- ...headers,
2784
- },
2797
+ credential: await credentialFor(session),
2798
+ headers,
2785
2799
  body,
2786
2800
  });
2787
2801
  }
@@ -3282,7 +3296,7 @@ export function registerSessionCommand(program) {
3282
3296
  env: process.env,
3283
3297
  autoRotate: false,
3284
3298
  });
3285
- if (!session?.token || !session?.apiUrl) {
3299
+ if (!isAuthenticated(session) || !session?.apiUrl) {
3286
3300
  throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
3287
3301
  }
3288
3302
  const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
@@ -3291,7 +3305,7 @@ export function registerSessionCommand(program) {
3291
3305
  {
3292
3306
  method: "POST",
3293
3307
  operationName: "session.set_title",
3294
- headers: { Authorization: `Bearer ${session.token}` },
3308
+ credential: session.credential,
3295
3309
  body: { title: normalizedTitle },
3296
3310
  },
3297
3311
  );
@@ -3332,7 +3346,7 @@ export function registerSessionCommand(program) {
3332
3346
  env: process.env,
3333
3347
  autoRotate: false,
3334
3348
  });
3335
- if (!session?.token || !session?.apiUrl) {
3349
+ if (!isAuthenticated(session) || !session?.apiUrl) {
3336
3350
  throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
3337
3351
  }
3338
3352
  const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
@@ -3341,7 +3355,7 @@ export function registerSessionCommand(program) {
3341
3355
  {
3342
3356
  method: "POST",
3343
3357
  operationName: "session.sweep_empty",
3344
- headers: { Authorization: `Bearer ${session.token}` },
3358
+ credential: session.credential,
3345
3359
  body: {
3346
3360
  cutoffMinutes,
3347
3361
  maxEvents,
@@ -4282,6 +4296,21 @@ export function registerSessionCommand(program) {
4282
4296
  );
4283
4297
  }
4284
4298
  const agentId = identity.agentId;
4299
+ if (isSessionReactionType(normalizedActionType)) {
4300
+ return runReactionCommand({
4301
+ sessionId: normalizedSessionId,
4302
+ reaction: normalizedActionType,
4303
+ agentId,
4304
+ targetPath,
4305
+ targetSequenceId,
4306
+ targetCursor,
4307
+ targetActionId,
4308
+ note,
4309
+ options,
4310
+ command,
4311
+ commandName,
4312
+ });
4313
+ }
4285
4314
  const idempotencyKey =
4286
4315
  normalizeString(options.idempotencyKey) ||
4287
4316
  defaultActionIdempotencyKey({
@@ -4364,6 +4393,89 @@ export function registerSessionCommand(program) {
4364
4393
  return payload;
4365
4394
  }
4366
4395
 
4396
+ // like/dislike/unlike/undislike: the shared reaction path (src/session/reactions.js)
4397
+ // decides admission, the operation key and the outcome; this only mirrors and prints.
4398
+ // `--idempotency-key` is a RETRY of the intent that minted that key.
4399
+ async function runReactionCommand({
4400
+ sessionId,
4401
+ reaction,
4402
+ agentId,
4403
+ targetPath,
4404
+ targetSequenceId,
4405
+ targetCursor,
4406
+ targetActionId,
4407
+ note,
4408
+ options,
4409
+ command,
4410
+ commandName,
4411
+ }) {
4412
+ const outcome = await submitSessionReaction({
4413
+ surface: "cli",
4414
+ sessionId,
4415
+ agentId,
4416
+ reaction,
4417
+ targetSequenceId,
4418
+ targetCursor,
4419
+ targetActionId,
4420
+ note,
4421
+ idempotencyKey: options.idempotencyKey,
4422
+ targetPath,
4423
+ });
4424
+ let event = null;
4425
+ let localAppend = { appended: false, reason: "not_recorded" };
4426
+ if (outcome.action) {
4427
+ const appended = await appendActionEventIfMissing(
4428
+ sessionId,
4429
+ buildSessionActionEvent(sessionId, outcome.action),
4430
+ { targetPath },
4431
+ );
4432
+ event = appended.event;
4433
+ localAppend = { appended: Boolean(appended.appended), reason: appended.reason || "" };
4434
+ }
4435
+ let evidenceEvent = null;
4436
+ if (outcome.collapsedActionId && outcome.action) {
4437
+ // Mirror the server's retained no-op row: this request's key, collapsed into
4438
+ // the active reaction it repeated.
4439
+ const appended = await appendActionEventIfMissing(
4440
+ sessionId,
4441
+ buildSessionActionEvent(sessionId, {
4442
+ id: outcome.collapsedActionId,
4443
+ actionType: reaction,
4444
+ targetSequenceId: outcome.action.targetSequenceId,
4445
+ targetCursor: outcome.action.targetCursor,
4446
+ targetActionId: outcome.action.targetActionId,
4447
+ actorKind: outcome.action.actorKind,
4448
+ actorId: outcome.action.actorId,
4449
+ actorRole: outcome.action.actorRole,
4450
+ idempotencyKey: outcome.operationKey,
4451
+ collapsedIntoActionId: outcome.action.id,
4452
+ }),
4453
+ { targetPath },
4454
+ );
4455
+ evidenceEvent = appended.event;
4456
+ }
4457
+ const payload = {
4458
+ command: commandName,
4459
+ targetPath,
4460
+ ...outcome,
4461
+ event,
4462
+ evidenceEvent,
4463
+ localAppend,
4464
+ };
4465
+ if (!outcome.ok) process.exitCode = 1;
4466
+ if (shouldEmitJson(options, command)) {
4467
+ console.log(JSON.stringify(payload, null, 2));
4468
+ return payload;
4469
+ }
4470
+ if (!outcome.ok) {
4471
+ console.error(pc.yellow(outcome.message));
4472
+ return payload;
4473
+ }
4474
+ if (event) console.log(formatEventLine(event));
4475
+ if (outcome.outcome !== "applied" || !event) console.log(pc.gray(outcome.message));
4476
+ return payload;
4477
+ }
4478
+
4367
4479
  session
4368
4480
  .command("actions")
4369
4481
  .description("List supported low-noise message actions with examples")
@@ -4390,7 +4502,7 @@ export function registerSessionCommand(program) {
4390
4502
  session
4391
4503
  .command("action <sessionId> <actionType>")
4392
4504
  .description(
4393
- "Create a message action for a target session event (ack, working_on, reply/comment, like, dislike, disregard, view)",
4505
+ "Create a message action for a target session event (ack, working_on, reply/comment, like, dislike, unlike, undislike, disregard, view)",
4394
4506
  )
4395
4507
  .option("--target-sequence <n>", "Target event sequence id")
4396
4508
  .option("--target-cursor <cursor>", "Target event cursor")
@@ -4406,18 +4518,23 @@ export function registerSessionCommand(program) {
4406
4518
 
4407
4519
  session
4408
4520
  .command("react <sessionId> <reaction>")
4409
- .description("React to or acknowledge a target session event with ack, like, or dislike")
4521
+ .description(
4522
+ "React to or acknowledge a target session event with ack, like, or dislike; retract your own like/dislike with unlike/undislike",
4523
+ )
4410
4524
  .option("--target-sequence <n>", "Target event sequence id")
4411
4525
  .option("--target-cursor <cursor>", "Target event cursor")
4412
4526
  .option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
4413
4527
  .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
4414
- .option("--idempotency-key <key>", "Explicit idempotency key")
4528
+ .option(
4529
+ "--idempotency-key <key>",
4530
+ "Retry an earlier reaction as the SAME intent by resending its operationKey (without it, each call is a new intent)",
4531
+ )
4415
4532
  .option("--path <path>", "Workspace path for the session", ".")
4416
4533
  .option("--json", "Emit machine-readable output")
4417
4534
  .action(async (sessionId, reaction, options, command) => {
4418
4535
  const normalizedReaction = normalizeSessionMessageActionType(reaction);
4419
- if (!["ack", "like", "dislike"].includes(normalizedReaction)) {
4420
- throw new Error("reaction must be one of: ack, like, dislike.");
4536
+ if (!SESSION_REACT_COMMAND_TYPES.includes(normalizedReaction)) {
4537
+ throw new Error(`reaction must be one of: ${SESSION_REACT_COMMAND_TYPES.join(", ")}.`);
4421
4538
  }
4422
4539
  await runMessageActionCommand({
4423
4540
  sessionId,
@@ -6273,7 +6390,7 @@ export function registerSessionCommand(program) {
6273
6390
  env: process.env,
6274
6391
  autoRotate: false,
6275
6392
  });
6276
- if (!authSession || !authSession.token) {
6393
+ if (!isAuthenticated(authSession)) {
6277
6394
  throw new Error(`Remote session read requires authentication. Run \`${authLoginHint()}\` first.`);
6278
6395
  }
6279
6396
  hydration = await hydrateSessionFromRemote({
@@ -6547,13 +6664,13 @@ export function registerSessionCommand(program) {
6547
6664
  autoRotate: false,
6548
6665
  }).catch(() => null);
6549
6666
  const apiBaseUrl = session?.apiUrl || "";
6550
- const token = session?.token || "";
6667
+ const credential = session?.credential || null;
6551
6668
  try {
6552
6669
  for await (const item of mergeLiveSources({
6553
6670
  sessionId: normalizedSessionId,
6554
6671
  targetPath,
6555
6672
  apiBaseUrl: apiBaseUrl || undefined,
6556
- token: token || undefined,
6673
+ credential: credential || undefined,
6557
6674
  signal: ac.signal,
6558
6675
  })) {
6559
6676
  if (item.event) {
@@ -8476,21 +8593,19 @@ export function registerSessionCommand(program) {
8476
8593
  return;
8477
8594
  }
8478
8595
 
8479
- let storedSession = null;
8480
- try {
8481
- storedSession = await readStoredSession();
8482
- } catch {
8483
- storedSession = null;
8484
- }
8596
+ const storedSession = await resolveActiveAuthSession({
8597
+ cwd: process.cwd(),
8598
+ env: process.env,
8599
+ autoRotate: false,
8600
+ }).catch(() => null);
8485
8601
 
8486
- const fetchCredentials =
8487
- storedSession && storedSession.token
8488
- ? () =>
8489
- fetchAidenIdCredentials({
8490
- apiUrl: storedSession.apiUrl,
8491
- token: storedSession.token,
8492
- })
8493
- : null;
8602
+ const fetchCredentials = isAuthenticated(storedSession)
8603
+ ? () =>
8604
+ fetchAidenIdCredentials({
8605
+ apiUrl: storedSession.apiUrl,
8606
+ auth: storedSession,
8607
+ })
8608
+ : null;
8494
8609
  const credentials = await resolveAidenIdCredentials({
8495
8610
  apiKey: options.apiKey,
8496
8611
  orgId: options.orgId,
@@ -10,7 +10,7 @@ import {
10
10
  resolveProvider,
11
11
  } from "../ai/client.js";
12
12
  import { recordCliLlmSessionUsage, usageNumber } from "../billing/llm-session-usage.js";
13
- import { loadConfig } from "../config/service.js";
13
+ import { loadConfig, userConfigValues } from "../config/service.js";
14
14
  import { evaluateBudget } from "../cost/budget.js";
15
15
  import { appendCostEntry, summarizeCostHistory } from "../cost/history.js";
16
16
  import { estimateModelCost } from "../cost/tracker.js";
@@ -265,7 +265,7 @@ async function maybeEnhanceSpecWithAi({
265
265
  configModel: config.resolved.defaultModelId,
266
266
  });
267
267
  const explicitApiKey = String(options.apiKey || "").trim();
268
- const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, config.resolved);
268
+ const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, userConfigValues(config));
269
269
 
270
270
  const prompt = buildAiSpecPrompt({
271
271
  baseSpecMarkdown,
@@ -13,6 +13,7 @@ import {
13
13
  } from "../auth/service.js";
14
14
  import { resolveOutputRoot } from "../config/service.js";
15
15
  import { authLoginHint } from "../ui/command-hints.js";
16
+ import { isAuthenticated } from "../auth/credential-destinations.js";
16
17
 
17
18
  const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "cancelled"]);
18
19
 
@@ -369,7 +370,7 @@ export function registerWatchCommand(program) {
369
370
  throw new Error(formatApiError(error));
370
371
  }
371
372
 
372
- if (!session || !session.token) {
373
+ if (!isAuthenticated(session)) {
373
374
  throw new Error(`No active auth token found. Run \`${authLoginHint()}\` first.`);
374
375
  }
375
376
 
@@ -395,7 +396,7 @@ export function registerWatchCommand(program) {
395
396
  while (true) {
396
397
  const response = await listRuntimeRunEvents({
397
398
  apiUrl: session.apiUrl,
398
- authToken: session.token,
399
+ credential: session.credential,
399
400
  runId,
400
401
  afterEventId,
401
402
  });
@@ -428,7 +429,7 @@ export function registerWatchCommand(program) {
428
429
 
429
430
  const statusResponse = await getRuntimeRunStatus({
430
431
  apiUrl: session.apiUrl,
431
- authToken: session.token,
432
+ credential: session.credential,
432
433
  runId,
433
434
  });
434
435
  latestStatus = String(statusResponse?.status || "unknown").trim().toLowerCase();