@cspeach/cli 1.1.33 → 1.1.34

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 (99) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +61 -8
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/licence/licence-check.js +205 -0
  68. package/dist/licence/paths.js +6 -0
  69. package/dist/licence/turn-gate.js +66 -0
  70. package/dist/models/model-identity.js +65 -0
  71. package/dist/models/resolve.js +8 -0
  72. package/dist/models/server-config.js +3 -2
  73. package/dist/one-shot.js +86 -2
  74. package/dist/renderer/footer-line.js +7 -1
  75. package/dist/renderer/status-footer.js +13 -6
  76. package/dist/repl/ask-or-no.js +18 -0
  77. package/dist/repl/builtin-commands.js +10 -2
  78. package/dist/repl/post-turn-status.js +3 -2
  79. package/dist/repl/safety-confirm.js +47 -2
  80. package/dist/repl/session-spend-line.js +4 -1
  81. package/dist/repl/startup-account.js +51 -0
  82. package/dist/repl.js +211 -48
  83. package/dist/router/byok-classifier.js +41 -14
  84. package/dist/router/classifier.js +5 -16
  85. package/dist/router/router-target.js +41 -0
  86. package/dist/sap/onboarding.js +70 -64
  87. package/dist/session/audit-export.js +11 -4
  88. package/dist/skills/manifest-client.js +37 -4
  89. package/dist/skills/skills-cache.js +115 -0
  90. package/dist/skills/source-authed.js +276 -0
  91. package/dist/tools/filesystem/glob.js +12 -3
  92. package/dist/tools/filesystem/grep.js +14 -3
  93. package/dist/tools/tool-loading.js +9 -10
  94. package/dist/usage/usage-queue.js +323 -0
  95. package/dist/usage/usage-reporter.js +270 -0
  96. package/package.json +3 -2
  97. package/dist/skills/bundled-skills.js +0 -286
  98. package/dist/skills/source-bundled.js +0 -20
  99. package/dist/skills/source-manifest.js +0 -36
@@ -8,22 +8,30 @@
8
8
  * read of the process-wide cost display mode (cost/credits.ts); no
9
9
  * inquirer, so the router can import it.
10
10
  */
11
- import { creditsActive } from '../cost/credits.js';
11
+ import { creditsActive, tokensOnlyActive } from '../cost/credits.js';
12
12
  // Pure generated data (type-only import inside), so this file stays light.
13
13
  import { BUNDLED_RUNS } from '../runs/bundled-runs.js';
14
14
  export const COST_DESCRIPTION_USD = 'Show this session\'s spend so far ($ and tokens)';
15
15
  export const COST_DESCRIPTION_CREDITS = 'Show the credits this session has used so far';
16
+ /** Azure tenant mode (Task 7, ruling 5): tokens only — never "$" or credits. */
17
+ export const COST_DESCRIPTION_TOKENS = "Show this session's tokens so far";
16
18
  /** Piece 2 §10: managed customers see credits, never "$" (cost/display-mode.ts). */
17
19
  /** /usage (2026-10-09): this month in credits — see commands/usage.ts. */
18
20
  export const USAGE_DESCRIPTION = 'Show this month’s credits, your balance and the top skills';
21
+ export const USAGE_DESCRIPTION_TOKENS = "Show where your usage is: your company's Azure invoice";
19
22
  export function costCommandDescription() {
23
+ if (tokensOnlyActive())
24
+ return COST_DESCRIPTION_TOKENS;
20
25
  return creditsActive() ? COST_DESCRIPTION_CREDITS : COST_DESCRIPTION_USD;
21
26
  }
27
+ export function usageCommandDescription() {
28
+ return tokensOnlyActive() ? USAGE_DESCRIPTION_TOKENS : USAGE_DESCRIPTION;
29
+ }
22
30
  export const BUILTIN_ENTRIES = [
23
31
  // Piece 2: /cancel is not listed — it exists only in the classic loop
24
32
  // (repl.tsx), and Esc / Ctrl+C already cancel under Ink.
25
33
  { name: '/cost', get description() { return costCommandDescription(); } },
26
- { name: '/usage', description: USAGE_DESCRIPTION },
34
+ { name: '/usage', get description() { return usageCommandDescription(); } },
27
35
  { name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
28
36
  { name: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
29
37
  { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
@@ -21,7 +21,7 @@ import { computeCost } from '../cost/pricing.js';
21
21
  import { computeSessionCost } from '../cost/session-cost.js';
22
22
  import { globalStore } from '../ui/sap-state-store.js';
23
23
  import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
24
- import { globalCredits } from '../cost/credits.js';
24
+ import { globalCredits, tokensOnlyActive } from '../cost/credits.js';
25
25
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
26
26
  import { shouldUseInk } from '../renderer/tty.js';
27
27
  import { isUserPrompt } from '../session/user-prompt.js';
@@ -49,7 +49,8 @@ export function computeTurnFacts(session, snapshot, turnModel) {
49
49
  const userTurns = session.messages.filter(isUserPrompt).length;
50
50
  const lifetimeAfter = session.usage.input_tokens + session.usage.output_tokens;
51
51
  const turnTokens = Math.max(0, lifetimeAfter - (snapshot.input + snapshot.output));
52
- const turnCost = computeCost(turnModel ?? session.model, {
52
+ // Azure: no price for the customer's deployment — never consult one.
53
+ const turnCost = tokensOnlyActive() ? 0 : computeCost(turnModel ?? session.model, {
53
54
  input: session.usage.input_tokens - snapshot.input,
54
55
  output: session.usage.output_tokens - snapshot.output,
55
56
  cacheRead: session.usage.cache_read_input_tokens - snapshot.cacheRead,
@@ -29,6 +29,8 @@ const RULE_LABEL = {
29
29
  RULE_9_TRANSPORT_ISOLATION: 'RULE 9 — transport isolation',
30
30
  RULE_10_VERIFY_AFTER_WRITE: 'RULE 10 — verify after write',
31
31
  CREDENTIAL_HANDOVER: 'SAP login handed to a local process',
32
+ RISKY_COMMAND: 'risky command (known pattern)',
33
+ CREDENTIAL_FILE_READ: 'credential file read',
32
34
  };
33
35
  /** Piece 2 §5 — plain-words heading: what could go wrong. No rule codes, no tool names. */
34
36
  export const SAFETY_HEADING = {
@@ -39,6 +41,8 @@ export const SAFETY_HEADING = {
39
41
  RULE_9_TRANSPORT_ISOLATION: 'This change may land in a transport shared with other work',
40
42
  RULE_10_VERIFY_AFTER_WRITE: 'This change is not proven until it is checked',
41
43
  CREDENTIAL_HANDOVER: 'Your SAP login will be handed to a local program',
44
+ RISKY_COMMAND: 'This command matches a known risky pattern',
45
+ CREDENTIAL_FILE_READ: 'This reads a file that may hold passwords or keys',
42
46
  };
43
47
  /** Bottom-edge note: which rule is asking. */
44
48
  export const SAFETY_FOOTNOTE = {
@@ -49,6 +53,8 @@ export const SAFETY_FOOTNOTE = {
49
53
  RULE_9_TRANSPORT_ISOLATION: 'Forge Rule 9',
50
54
  RULE_10_VERIFY_AFTER_WRITE: 'Forge Rule 10',
51
55
  CREDENTIAL_HANDOVER: 'Credential handover',
56
+ RISKY_COMMAND: 'Risky command check',
57
+ CREDENTIAL_FILE_READ: 'Credential file check',
52
58
  };
53
59
  /** Rule 7 covers both overwrite and delete; the delete says so. */
54
60
  export function safetyHeading(a) {
@@ -103,8 +109,40 @@ function whatLabel(a) {
103
109
  return fallback;
104
110
  return toolActivityLabel(tool, args);
105
111
  }
112
+ /** The risk gate's two rules — their subject is a model-written command or path. */
113
+ function isRiskRule(a) {
114
+ return a.rule === 'RISKY_COMMAND' || a.rule === 'CREDENTIAL_FILE_READ';
115
+ }
116
+ /**
117
+ * Fix round 1 (F9) — the most of a risky command or path the card shows. A
118
+ * longer one shows its head and its tail with the hidden length between them
119
+ * (fix round 2, L2: a command padded past the cap cannot hide its target on
120
+ * the classic card, which has no scrollback copy). Ink also writes the whole
121
+ * text to scrollback first, like the Rule 8 summary. Never redacted.
122
+ */
123
+ export const RISK_SUBJECT_MAX = 300;
124
+ const RISK_SUBJECT_HEAD = 200;
125
+ const RISK_SUBJECT_TAIL = 80;
126
+ /** The subject as shown on a card: display-cleaned (F8), cut to head + tail past RISK_SUBJECT_MAX (F9, L2). */
127
+ function riskSubject(name) {
128
+ const c = clean(name);
129
+ if (c.length <= RISK_SUBJECT_MAX)
130
+ return c;
131
+ const hidden = c.length - RISK_SUBJECT_HEAD - RISK_SUBJECT_TAIL;
132
+ return `${c.slice(0, RISK_SUBJECT_HEAD)} ${getGlyphs().more} (+${hidden} chars) ${c.slice(-RISK_SUBJECT_TAIL)}`;
133
+ }
106
134
  /** What / Where / Undo rows (plain text; the card paints them dim). */
107
135
  export function safetyFacts(a) {
136
+ // Security batch item 4 — the risk gate's cards: the exact command or path,
137
+ // why it was flagged, and that it runs on this computer.
138
+ if (isRiskRule(a)) {
139
+ return [
140
+ `What ${a.rule === 'RISKY_COMMAND' ? 'Run' : 'Read'} ${riskSubject(a.object.name)}`,
141
+ ...boundedWhatLines(whatLines(a)).map((t) => ` ${t}`),
142
+ 'Where this computer',
143
+ `Undo ${a.rule === 'RISKY_COMMAND' ? 'Cannot be undone' : 'Once read, its text goes to the model'}`,
144
+ ];
145
+ }
108
146
  const what = a.rule === 'CREDENTIAL_HANDOVER'
109
147
  ? `Hand the SAP login for ${clean(a.object.name)} to a local program`
110
148
  : whatLabel(a);
@@ -162,6 +200,10 @@ async function confirmOnCards(action) {
162
200
  // The card keeps the header and the last lines; the whole text lands above it.
163
201
  emitTrace(what.join('\n'));
164
202
  }
203
+ if (isRiskRule(action) && clean(action.object.name).length > RISK_SUBJECT_MAX) {
204
+ // F9 — the card shows the cut; the whole (cleaned) command lands above it.
205
+ emitTrace(clean(action.object.name));
206
+ }
165
207
  const first = await askConfirm({
166
208
  id: 'safety-confirm',
167
209
  tag: release ? 'CANNOT BE UNDONE' : 'SAFETY CHECK',
@@ -260,14 +302,17 @@ function renderCard(action) {
260
302
  lines.push('');
261
303
  lines.push(`${chalk.yellow(getGlyphs().attention)} ${chalk.yellow.bold(RULE_LABEL[action.rule])}`);
262
304
  lines.push(` ${chalk.dim('op: ')} ${action.op}`);
263
- lines.push(` ${chalk.dim('object: ')} ${action.object.type} ${action.object.name}`);
305
+ // F8 — a risk card's subject is model-written: cleaned (no terminal escapes)
306
+ // and cut (F9). Other rules print exactly as before.
307
+ const risk = isRiskRule(action);
308
+ lines.push(` ${chalk.dim('object: ')} ${action.object.type} ${risk ? riskSubject(action.object.name) : action.object.name}`);
264
309
  lines.push(` ${chalk.dim('transport:')} ${transport}`);
265
310
  if (action.rule === 'RULE_8_BATCH_PLAN' && typeof action.batch_count === 'number') {
266
311
  lines.push(` ${chalk.dim('batch: ')} ${action.batch_count} objects this turn`);
267
312
  }
268
313
  // what_will_happen may include embedded newlines (e.g. Rule 8 plan summary).
269
314
  // Indent the continuation lines so they align under the header field.
270
- const whatLines = action.what_will_happen.split('\n');
315
+ const whatLines = risk ? action.what_will_happen.split('\n').map(clean) : action.what_will_happen.split('\n');
271
316
  if (whatLines.length === 1) {
272
317
  lines.push(` ${chalk.dim('what: ')} ${whatLines[0]}`);
273
318
  }
@@ -6,9 +6,12 @@
6
6
  * credits existed: ` spend: $1.37`.
7
7
  */
8
8
  import { formatCost } from '../cost/pricing.js';
9
- import { creditsActive, formatCredits, CREDITS_PENDING } from '../cost/credits.js';
9
+ import { creditsActive, tokensOnlyActive, formatCredits, CREDITS_PENDING } from '../cost/credits.js';
10
+ import { AZURE_INVOICE_LINE } from '../cost/display-mode.js';
10
11
  /** The spend VALUE in the current display mode (piece 2: goodbye block). */
11
12
  export function formatSessionSpendValue(sessionCostUsd, credits) {
13
+ if (tokensOnlyActive())
14
+ return AZURE_INVOICE_LINE;
12
15
  if (!creditsActive())
13
16
  return formatCost(sessionCostUsd);
14
17
  if (!credits)
@@ -0,0 +1,51 @@
1
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
2
+ export async function runStartupAccount(deps, io) {
3
+ let licenceCheckedAtMs;
4
+ try {
5
+ const cfgEarly = await deps.loadConfig();
6
+ if (cfgEarly.llm.mode === 'managed') {
7
+ const authFile = await deps.readAuthFile();
8
+ const startupKey = authFile ? (await deps.resolveApiKey()).key : null;
9
+ if (authFile && startupKey) {
10
+ const meResult = await deps.fetchMe({ proxyUrl: cfgEarly.proxy_url, apiKey: startupKey });
11
+ if (!meResult.ok) {
12
+ if (meResult.status === 401) {
13
+ io.closeBrand();
14
+ io.print(formatFailure({ what: 'Your CSPeach sign-in is no longer valid', next: 'cspeach login' }));
15
+ return { exit: 1 };
16
+ }
17
+ io.notice(formatInfo(`Could not refresh your account (${meResult.error})`));
18
+ }
19
+ else {
20
+ io.showProfile(meResult.profile);
21
+ }
22
+ }
23
+ }
24
+ else {
25
+ const { key: startupKey } = await deps.resolveApiKey();
26
+ if (startupKey) {
27
+ const licence = await deps.runLicenceCheck({ proxyUrl: cfgEarly.proxy_url, apiKey: startupKey }, { headers: await deps.licenceCheckHeaders(cfgEarly.llm.mode).catch(() => ({})) });
28
+ licenceCheckedAtMs = (deps.now ?? Date.now)();
29
+ if (!licence.allow) {
30
+ io.closeBrand();
31
+ io.print(licence.message);
32
+ return { exit: 1 };
33
+ }
34
+ if (licence.verdict === 'unknown') {
35
+ io.notice(formatInfo(licence.line));
36
+ }
37
+ else if (licence.profile) {
38
+ io.showProfile(licence.profile);
39
+ }
40
+ if (licence.verdict === 'ok') {
41
+ try {
42
+ deps.startSkillsWarmup?.(cfgEarly, licence.verdict);
43
+ }
44
+ catch { /* never blocks start-up */ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ catch { /* banner is best-effort — never block REPL startup */ }
50
+ return licenceCheckedAtMs === undefined ? { exit: null } : { exit: null, licenceCheckedAtMs };
51
+ }
package/dist/repl.js CHANGED
@@ -27,7 +27,12 @@ import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
27
27
  import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
28
28
  import { getStore } from './auth/api-key.js';
29
29
  import { readAuthFile } from './auth/auth-file.js';
30
+ import { resolveApiKey } from './auth/key-resolver.js';
30
31
  import { fetchMe } from './auth/me.js';
32
+ import { runLicenceCheck, licenceCheckHeaders } from './licence/licence-check.js';
33
+ import { LicenceTurnGate } from './licence/turn-gate.js';
34
+ import { runStartupAccount } from './repl/startup-account.js';
35
+ import { startSkillsCacheWarmup } from './skills/source-authed.js';
31
36
  import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
32
37
  import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
33
38
  import { paint } from './renderer/theme.js';
@@ -39,15 +44,18 @@ import { AdtClient, snapshots } from '@cspeach/sap-client';
39
44
  import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
40
45
  import { newSession } from './session/schema.js';
41
46
  import { resolveModelRole } from './models/resolve.js';
47
+ import { familyOf, planTierModelFor, getActiveDeployments } from './models/model-identity.js';
42
48
  import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
43
49
  import { prefetchRouterPrompt } from './router/router-prompt.js';
50
+ import { profileForMode } from './agent/connection-profile.js';
44
51
  import { repinResumedSessionModel } from './session/repin-model.js';
45
52
  import { computeCost } from './cost/pricing.js';
46
53
  import { formatSessionSpendValue } from './repl/session-spend-line.js';
47
54
  import { formatGoodbye } from './renderer/goodbye.js';
48
- import { globalCredits } from './cost/credits.js';
55
+ import { globalCredits, tokensOnlyActive } from './cost/credits.js';
49
56
  import { initCredits } from './cost/credits-wire.js';
50
57
  import { createProviderForMode } from './agent/providers/factory.js';
58
+ import { startUsageFlushAtStartup } from './usage/usage-reporter.js';
51
59
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
52
60
  import { runTurn } from './agent/loop.js';
53
61
  import { IdleKeepalive } from './agent/cache-keepalive.js';
@@ -294,7 +302,8 @@ function existingStreamDir(sessionId) {
294
302
  }
295
303
  function printGoodbye(session) {
296
304
  const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
297
- const sessionCost = computeCost(session.model, {
305
+ // Azure: no price for the customer's deployment — never consult one.
306
+ const sessionCost = tokensOnlyActive() ? 0 : computeCost(session.model, {
298
307
  input: session.usage.input_tokens ?? 0,
299
308
  output: session.usage.output_tokens ?? 0,
300
309
  cacheRead: session.usage.cache_read_input_tokens ?? 0,
@@ -457,46 +466,51 @@ export async function runRepl(opts) {
457
466
  }
458
467
  }
459
468
  catch { /* startup banner is best-effort */ }
460
- // Customer-onboarding (Task 9): if the new auth-file is present, hit /v1/me
461
- // and render the verbose login banner. Falls through to the legacy keytar
462
- // flow below when the file is absent (back-compat for pre-`cspeach login`
463
- // users).
469
+ // Customer-onboarding (Task 9) + security batch item 1.
470
+ // Managed: today's call exactly — only when auth.json exists, the key is
471
+ // resolved only then, the same request, and a 401 exits. Falls through to the
472
+ // legacy keytar flow below when the file is absent (back-compat for
473
+ // pre-`cspeach login` users).
474
+ // byok / ai-hub / local: the shared licence check — server verdict, 72-hour
475
+ // offline grace, licence.json — whether or not auth.json exists. It IS this
476
+ // start-up /v1/me call (one request, the same 5 s budget), never a second one.
477
+ // The block lives in repl/startup-account.ts (behaviour-identical lift, driven
478
+ // by repl/__tests__/startup-account.test.ts).
464
479
  let signIn;
465
480
  let signedInAs;
466
- try {
467
- const authFile = await readAuthFile();
468
- if (authFile) {
469
- const cfgEarly = await loadConfig();
470
- const meResult = await fetchMe({ proxyUrl: cfgEarly.proxy_url, apiKey: authFile.apiKey });
471
- if (!meResult.ok) {
472
- if (meResult.status === 401) {
473
- earlyBrand?.close();
474
- console.log(formatFailure({ what: 'Your CSPeach sign-in is no longer valid', next: 'cspeach login' }));
475
- return 1;
476
- }
477
- const note = formatInfo(`Could not refresh your account (${meResult.error})`);
478
- if (getLook() === 'panels')
479
- startupNotices.push(note);
480
- else
481
- console.log(note.trimEnd());
482
- }
483
- else {
484
- signedInAs = meResult.profile.email;
485
- const banner = formatLoginBanner(meResult.profile);
486
- if (getLook() === 'panels') {
487
- signIn = signInFragment(meResult.profile);
488
- if (banner.warningLine)
489
- startupNotices.push(formatAttention({ text: banner.warningLine }));
490
- }
491
- else {
492
- console.log(banner.line);
493
- if (banner.warningLine)
494
- console.log(formatAttention({ text: banner.warningLine }).trimEnd());
495
- }
496
- }
481
+ const showProfile = (profile) => {
482
+ signedInAs = profile.email;
483
+ const banner = formatLoginBanner(profile);
484
+ if (getLook() === 'panels') {
485
+ signIn = signInFragment(profile);
486
+ if (banner.warningLine)
487
+ startupNotices.push(formatAttention({ text: banner.warningLine }));
497
488
  }
498
- }
499
- catch { /* banner is best-effort — never block REPL startup */ }
489
+ else {
490
+ console.log(banner.line);
491
+ if (banner.warningLine)
492
+ console.log(formatAttention({ text: banner.warningLine }).trimEnd());
493
+ }
494
+ };
495
+ const startupAccount = await runStartupAccount({
496
+ loadConfig, readAuthFile, resolveApiKey: () => resolveApiKey(), fetchMe, runLicenceCheck, licenceCheckHeaders,
497
+ // Final review finding 1 — fill the skills cache for offline use. Fire-and-forget.
498
+ startSkillsWarmup: (c, verdict) => { void startSkillsCacheWarmup(c, verdict); },
499
+ }, {
500
+ print: (line) => console.log(line),
501
+ notice: (note) => {
502
+ if (getLook() === 'panels')
503
+ startupNotices.push(note);
504
+ else
505
+ console.log(note.trimEnd());
506
+ },
507
+ showProfile,
508
+ closeBrand: () => earlyBrand?.close(),
509
+ });
510
+ if (startupAccount.exit === 1)
511
+ return 1;
512
+ // Read by the between-turns recheck (Task 6): the 4-hour clock starts here.
513
+ const licenceCheckedAtMs = startupAccount.licenceCheckedAtMs;
500
514
  earlyBrand?.finish(signIn);
501
515
  const store = await getStore();
502
516
  const keychainMissing = !(await store.isKeychainAvailable());
@@ -601,8 +615,55 @@ export async function runRepl(opts) {
601
615
  fetchToolLoadingMode(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
602
616
  // 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
603
617
  // router prompt (public manifest, no prompt data) under the same cap.
604
- cfg.llm?.mode === 'byok' || cfg.llm?.mode === 'ai-hub' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
618
+ profileForMode(cfg.llm?.mode).router === 'direct' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
605
619
  ]);
620
+ // Azure tenant mode (Task 4) — the company's connection settings, before
621
+ // the session model is pinned (resolveModelRole reads the deployments).
622
+ // Cached connection.json, refreshed under the same 1.5 s cap; a changed
623
+ // endpoint is shown and asked about. A refusal is shown now and the gate
624
+ // refuses turns — never a fall-back to managed.
625
+ if (cfg.llm.mode === 'azure') {
626
+ const { refreshAzureConnection } = await import('./connection/startup.js');
627
+ const { confirm } = await import('./repl/themed-prompts.js');
628
+ const { askOrNo } = await import('./repl/ask-or-no.js');
629
+ const note = (line) => {
630
+ if (getLook() === 'panels')
631
+ startupNotices.push(line.endsWith('\n') ? line : `${line}\n`);
632
+ else
633
+ console.log(line.trimEnd());
634
+ };
635
+ // The "endpoint changed" rows must be on screen BEFORE the question, so
636
+ // they are held here and printed straight away when a question opens;
637
+ // anything left over joins the start-up notices.
638
+ const held = [];
639
+ const az = await refreshAzureConnection({
640
+ proxyUrl: cfg.proxy_url, apiKey: apiKey ?? null, interactive: true,
641
+ print: (line) => { held.push(line); },
642
+ confirm: async (message) => {
643
+ for (const line of held.splice(0))
644
+ console.log(line.trimEnd());
645
+ // Ctrl+C here is a no (pending kept), never an unhandled ExitPromptError.
646
+ return askOrNo((m) => confirm({ message: m, default: false }))(message);
647
+ },
648
+ });
649
+ for (const line of held)
650
+ note(line);
651
+ if (!az.verdict.allow)
652
+ note(az.verdict.message);
653
+ else if (az.cached) {
654
+ // Task 6b — the Microsoft sign-in, before the first turn: asks first,
655
+ // then the browser. Esc / Ctrl+C during it stops it at once (the
656
+ // listener closes). Its own lines (the address to copy when no browser
657
+ // opens) print straight away, never held for after the sign-in.
658
+ const { azureSignInAtStart } = await import('./auth/azure/startup-sign-in.js');
659
+ const { loginOpener } = await import('./util/open-in-browser.js');
660
+ const s = await azureSignInAtStart(az.cached.connection, {
661
+ ask: askOrNo((message) => confirm({ message, default: true })), print: (line) => console.log(line.trimEnd()), open: loginOpener(),
662
+ });
663
+ if (!s.ok)
664
+ note(s.message);
665
+ }
666
+ }
606
667
  // Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
607
668
  // A managed customer is billed in credits at a multiplier we do not
608
669
  // disclose, so the dollar figures (our Anthropic COGS) must never reach
@@ -815,6 +876,41 @@ export async function runRepl(opts) {
815
876
  throw new Error('No API key configured');
816
877
  return key;
817
878
  });
879
+ // Security batch item 1 — licence re-check between turns (byok / ai-hub /
880
+ // local, every 4 hours from the start-up check). Managed: never calls.
881
+ const licenceGate = new LicenceTurnGate({
882
+ mode: cfg.llm.mode,
883
+ proxyUrl: cfg.proxy_url,
884
+ getKey: async () => (await store.get()) ?? null,
885
+ // No start-up check ran (no key yet, e.g. pasted at the prompt below):
886
+ // non-managed checks on the very first turn. Managed never reads it.
887
+ lastCheckAtMs: licenceCheckedAtMs ?? (cfg.llm.mode === 'managed' ? Date.now() : -Infinity),
888
+ ...(cfg.llm.mode === 'azure' ? {
889
+ extra: async () => (await import('./connection/azure-gate.js')).azureGateCheck(),
890
+ // Plan D13 — never a prompt between turns (no inquirer inside the Ink
891
+ // UI): a changed endpoint or tenant is held as pending and the `extra`
892
+ // gate refuses with "…waiting for you to confirm it · start cspeach to
893
+ // review it". Nothing is printed here; the gate's refusal is the notice.
894
+ // So on this 4-hourly path the cache-write-failure line and the ignored
895
+ // env-override lines are dropped (they show again at the next start).
896
+ onRecheck: async () => {
897
+ const { refreshAzureConnection } = await import('./connection/startup.js');
898
+ await refreshAzureConnection({
899
+ proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? null, interactive: false,
900
+ print: () => { }, confirm: async () => false,
901
+ });
902
+ },
903
+ } : {}),
904
+ });
905
+ // Security batch item 3 — send the usage counts still waiting from earlier
906
+ // runs. Non-managed only (a managed seat makes no call); fire-and-forget:
907
+ // never awaited, never throws into start-up.
908
+ if (cfg.llm.mode !== 'managed') {
909
+ try {
910
+ startUsageFlushAtStartup(cfg);
911
+ }
912
+ catch { /* best effort */ }
913
+ }
818
914
  // Task 15 — keep the prompt cache warm while the user is idle between
819
915
  // turns: armed after a turn, disarmed on the next submit and on exit;
820
916
  // `[keepalive] idle_pings` per idle period (managed / BYOK only).
@@ -1148,6 +1244,16 @@ export async function runRepl(opts) {
1148
1244
  // older turns via Haiku so subsequent turns ingest ~5k tokens of
1149
1245
  // context instead of 500k+. Saves \$3-8 per follow-up turn.
1150
1246
  if (trimmed === '/compact') {
1247
+ // Security batch item 1 — /compact makes a model call: gated like a turn.
1248
+ {
1249
+ const g = await licenceGate.beforeTurn();
1250
+ if (!g.allow) {
1251
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1252
+ return;
1253
+ }
1254
+ if (g.line)
1255
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1256
+ }
1151
1257
  const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
1152
1258
  const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
1153
1259
  const { loadConfig } = await import('./config/loader.js');
@@ -1156,7 +1262,7 @@ export async function runRepl(opts) {
1156
1262
  return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
1157
1263
  // model-governance step 2d — compact model resolves env > local >
1158
1264
  // server > built-in (== COMPACTION_MODEL when nothing set).
1159
- { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
1265
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' }, sessionId: session.id });
1160
1266
  };
1161
1267
  try {
1162
1268
  chunkEmitter.emit('chunk', '\n' + formatInfo('Compacting older turns into a summary…'));
@@ -1219,6 +1325,15 @@ export async function runRepl(opts) {
1219
1325
  chunkEmitter.emit('chunk', '\n' + formatInfo(`Rerouting "${preview}" to /${rr.skill}`));
1220
1326
  if (rr.warning)
1221
1327
  chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
1328
+ {
1329
+ const g = await licenceGate.beforeTurn();
1330
+ if (!g.allow) {
1331
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1332
+ return;
1333
+ }
1334
+ if (g.line)
1335
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1336
+ }
1222
1337
  // Re-review 1 — its own AbortController, wired like the main path, so
1223
1338
  // Ctrl+C and `/exit` stop a rerouted turn the same way.
1224
1339
  const rerouteAbort = new AbortController();
@@ -1576,6 +1691,20 @@ export async function runRepl(opts) {
1576
1691
  session.last_object = extractedObject ?? previousObject ?? null;
1577
1692
  return;
1578
1693
  }
1694
+ // Security batch item 1 — never mid-turn: checked here, before the turn
1695
+ // and before any plan-resume work (run lease, object-list grant,
1696
+ // re-audit, phase context, plan_in_flight). A plan step refused here
1697
+ // never starts; the plan file keeps its state, so `/abap-plan --resume`
1698
+ // continues later.
1699
+ {
1700
+ const g = await licenceGate.beforeTurn();
1701
+ if (!g.allow) {
1702
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1703
+ return;
1704
+ }
1705
+ if (g.line)
1706
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1707
+ }
1579
1708
  // B3 (2026-06-06) — `/abap-plan --resume @plan-file`. Token-free
1580
1709
  // pre-work (load envelope, pick next eligible phase, print the
1581
1710
  // tracker board, inline the phase's rule files) happens in
@@ -1724,7 +1853,7 @@ export async function runRepl(opts) {
1724
1853
  const { beginPlanPhaseContext, makePhaseBoundarySummariser } = await import('./commands/plan-boundary.js');
1725
1854
  await beginPlanPhaseContext({
1726
1855
  session,
1727
- summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg)),
1856
+ summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg), session.id),
1728
1857
  log: (line) => chunkEmitter.emit('chunk', line + '\n'),
1729
1858
  });
1730
1859
  }
@@ -1752,7 +1881,9 @@ export async function runRepl(opts) {
1752
1881
  delegateTo: planResume.nextDelegateTo,
1753
1882
  // model-governance step 2d — the session default now resolves
1754
1883
  // env > local > server > built-in (== cfg.default_model when nothing set).
1755
- defaultModel: resolveModelRole('session_default', cfg),
1884
+ defaultModel: familyOf(resolveModelRole('session_default', cfg), provider),
1885
+ // Azure tenant mode (Task 2) — undefined (today's Sonnet) with no connection table.
1886
+ tierModel: planTierModelFor(getActiveDeployments()),
1756
1887
  });
1757
1888
  if (choice.model) {
1758
1889
  planModelOverride = choice.model;
@@ -2178,7 +2309,7 @@ export async function runRepl(opts) {
2178
2309
  session,
2179
2310
  config: { ...cfg.compact, auto_threshold_tokens: resolveAutoCompactThreshold(cfg) },
2180
2311
  queued: pendingDispatchAccessor.get() !== null,
2181
- summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg)),
2312
+ summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg), session.id),
2182
2313
  emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
2183
2314
  });
2184
2315
  }
@@ -3074,6 +3205,16 @@ export async function runRepl(opts) {
3074
3205
  // Summarises older turns via Haiku 4.5 so subsequent turns ingest ~5k
3075
3206
  // tokens of context instead of 500k+. Backs up session pre-mutation.
3076
3207
  if (trimmed === '/compact') {
3208
+ // Security batch item 1 — /compact makes a model call: gated like a turn.
3209
+ {
3210
+ const g = await licenceGate.beforeTurn();
3211
+ if (!g.allow) {
3212
+ console.log(g.message);
3213
+ continue;
3214
+ }
3215
+ if (g.line)
3216
+ console.log(formatInfo(g.line).trimEnd());
3217
+ }
3077
3218
  const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
3078
3219
  const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
3079
3220
  const { loadConfig } = await import('./config/loader.js');
@@ -3082,7 +3223,7 @@ export async function runRepl(opts) {
3082
3223
  return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
3083
3224
  // model-governance step 2d — compact model resolves env > local >
3084
3225
  // server > built-in (== COMPACTION_MODEL when nothing set).
3085
- { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
3226
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' }, sessionId: session.id });
3086
3227
  };
3087
3228
  try {
3088
3229
  console.log('\n' + formatInfo('Compacting older turns into a summary…').trimEnd());
@@ -3154,6 +3295,15 @@ export async function runRepl(opts) {
3154
3295
  console.log(formatInfo(`Rerouting "${preview}" to /${rr.skill}`).trimEnd());
3155
3296
  if (rr.warning)
3156
3297
  console.log(chalk.dim(` (${rr.warning})`));
3298
+ {
3299
+ const g = await licenceGate.beforeTurn();
3300
+ if (!g.allow) {
3301
+ console.log(g.message);
3302
+ continue;
3303
+ }
3304
+ if (g.line)
3305
+ console.log(formatInfo(g.line).trimEnd());
3306
+ }
3157
3307
  // A turn error prints the same stderr block as the normal turn path.
3158
3308
  await runRerouteTurn({
3159
3309
  run: () => runTurn({
@@ -3574,6 +3724,17 @@ export async function runRepl(opts) {
3574
3724
  session.last_object = extractedObject ?? previousObject ?? null;
3575
3725
  continue;
3576
3726
  }
3727
+ // Security batch item 1 — checked before the turn and before any
3728
+ // plan-resume work (classic mirror of the Ink path).
3729
+ {
3730
+ const g = await licenceGate.beforeTurn();
3731
+ if (!g.allow) {
3732
+ console.log(g.message);
3733
+ continue;
3734
+ }
3735
+ if (g.line)
3736
+ console.log(formatInfo(g.line).trimEnd());
3737
+ }
3577
3738
  // B3 (2026-06-06) — `/abap-plan --resume @plan-file` (classic-mode
3578
3739
  // mirror of the Ink path above; see that block for the full design
3579
3740
  // note). Token-free pre-work; terminal states end the turn with no
@@ -3700,7 +3861,7 @@ export async function runRepl(opts) {
3700
3861
  const { beginPlanPhaseContext, makePhaseBoundarySummariser } = await import('./commands/plan-boundary.js');
3701
3862
  await beginPlanPhaseContext({
3702
3863
  session,
3703
- summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg)),
3864
+ summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg), session.id),
3704
3865
  log: (line) => console.log(line),
3705
3866
  });
3706
3867
  }
@@ -3723,7 +3884,9 @@ export async function runRepl(opts) {
3723
3884
  delegateTo: planResume.nextDelegateTo,
3724
3885
  // model-governance step 2d — the session default now resolves
3725
3886
  // env > local > server > built-in (== cfg.default_model when nothing set).
3726
- defaultModel: resolveModelRole('session_default', cfg),
3887
+ defaultModel: familyOf(resolveModelRole('session_default', cfg), provider),
3888
+ // Azure tenant mode (Task 2) — undefined (today's Sonnet) with no connection table.
3889
+ tierModel: planTierModelFor(getActiveDeployments()),
3727
3890
  });
3728
3891
  if (choice.model) {
3729
3892
  planModelOverride = choice.model;
@@ -4127,7 +4290,7 @@ export async function runRepl(opts) {
4127
4290
  session,
4128
4291
  config: { ...cfg.compact, auto_threshold_tokens: resolveAutoCompactThreshold(cfg) },
4129
4292
  queued: pendingDispatchAccessor.get() !== null,
4130
- summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg)),
4293
+ summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg), session.id),
4131
4294
  emit: (line) => console.log(line),
4132
4295
  });
4133
4296
  }