@cspeach/cli 1.1.33 → 1.1.35

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 (119) 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 +182 -39
  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/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -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';
@@ -136,6 +144,7 @@ import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-m
136
144
  import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
137
145
  import { createRestoreGuard } from './repl/restore-guard.js';
138
146
  import { gracefulExit } from './lib/graceful-exit.js';
147
+ import { endSapSessionAfterTurn } from './sap/session-end.js';
139
148
  import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
140
149
  import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
141
150
  import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
@@ -294,7 +303,8 @@ function existingStreamDir(sessionId) {
294
303
  }
295
304
  function printGoodbye(session) {
296
305
  const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
297
- const sessionCost = computeCost(session.model, {
306
+ // Azure: no price for the customer's deployment — never consult one.
307
+ const sessionCost = tokensOnlyActive() ? 0 : computeCost(session.model, {
298
308
  input: session.usage.input_tokens ?? 0,
299
309
  output: session.usage.output_tokens ?? 0,
300
310
  cacheRead: session.usage.cache_read_input_tokens ?? 0,
@@ -457,46 +467,51 @@ export async function runRepl(opts) {
457
467
  }
458
468
  }
459
469
  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).
470
+ // Customer-onboarding (Task 9) + security batch item 1.
471
+ // Managed: today's call exactly — only when auth.json exists, the key is
472
+ // resolved only then, the same request, and a 401 exits. Falls through to the
473
+ // legacy keytar flow below when the file is absent (back-compat for
474
+ // pre-`cspeach login` users).
475
+ // byok / ai-hub / local: the shared licence check — server verdict, 72-hour
476
+ // offline grace, licence.json — whether or not auth.json exists. It IS this
477
+ // start-up /v1/me call (one request, the same 5 s budget), never a second one.
478
+ // The block lives in repl/startup-account.ts (behaviour-identical lift, driven
479
+ // by repl/__tests__/startup-account.test.ts).
464
480
  let signIn;
465
481
  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
- }
482
+ const showProfile = (profile) => {
483
+ signedInAs = profile.email;
484
+ const banner = formatLoginBanner(profile);
485
+ if (getLook() === 'panels') {
486
+ signIn = signInFragment(profile);
487
+ if (banner.warningLine)
488
+ startupNotices.push(formatAttention({ text: banner.warningLine }));
497
489
  }
498
- }
499
- catch { /* banner is best-effort — never block REPL startup */ }
490
+ else {
491
+ console.log(banner.line);
492
+ if (banner.warningLine)
493
+ console.log(formatAttention({ text: banner.warningLine }).trimEnd());
494
+ }
495
+ };
496
+ const startupAccount = await runStartupAccount({
497
+ loadConfig, readAuthFile, resolveApiKey: () => resolveApiKey(), fetchMe, runLicenceCheck, licenceCheckHeaders,
498
+ // Final review finding 1 — fill the skills cache for offline use. Fire-and-forget.
499
+ startSkillsWarmup: (c, verdict) => { void startSkillsCacheWarmup(c, verdict); },
500
+ }, {
501
+ print: (line) => console.log(line),
502
+ notice: (note) => {
503
+ if (getLook() === 'panels')
504
+ startupNotices.push(note);
505
+ else
506
+ console.log(note.trimEnd());
507
+ },
508
+ showProfile,
509
+ closeBrand: () => earlyBrand?.close(),
510
+ });
511
+ if (startupAccount.exit === 1)
512
+ return 1;
513
+ // Read by the between-turns recheck (Task 6): the 4-hour clock starts here.
514
+ const licenceCheckedAtMs = startupAccount.licenceCheckedAtMs;
500
515
  earlyBrand?.finish(signIn);
501
516
  const store = await getStore();
502
517
  const keychainMissing = !(await store.isKeychainAvailable());
@@ -601,8 +616,55 @@ export async function runRepl(opts) {
601
616
  fetchToolLoadingMode(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
602
617
  // 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
603
618
  // 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(),
619
+ profileForMode(cfg.llm?.mode).router === 'direct' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
605
620
  ]);
621
+ // Azure tenant mode (Task 4) — the company's connection settings, before
622
+ // the session model is pinned (resolveModelRole reads the deployments).
623
+ // Cached connection.json, refreshed under the same 1.5 s cap; a changed
624
+ // endpoint is shown and asked about. A refusal is shown now and the gate
625
+ // refuses turns — never a fall-back to managed.
626
+ if (cfg.llm.mode === 'azure') {
627
+ const { refreshAzureConnection } = await import('./connection/startup.js');
628
+ const { confirm } = await import('./repl/themed-prompts.js');
629
+ const { askOrNo } = await import('./repl/ask-or-no.js');
630
+ const note = (line) => {
631
+ if (getLook() === 'panels')
632
+ startupNotices.push(line.endsWith('\n') ? line : `${line}\n`);
633
+ else
634
+ console.log(line.trimEnd());
635
+ };
636
+ // The "endpoint changed" rows must be on screen BEFORE the question, so
637
+ // they are held here and printed straight away when a question opens;
638
+ // anything left over joins the start-up notices.
639
+ const held = [];
640
+ const az = await refreshAzureConnection({
641
+ proxyUrl: cfg.proxy_url, apiKey: apiKey ?? null, interactive: true,
642
+ print: (line) => { held.push(line); },
643
+ confirm: async (message) => {
644
+ for (const line of held.splice(0))
645
+ console.log(line.trimEnd());
646
+ // Ctrl+C here is a no (pending kept), never an unhandled ExitPromptError.
647
+ return askOrNo((m) => confirm({ message: m, default: false }))(message);
648
+ },
649
+ });
650
+ for (const line of held)
651
+ note(line);
652
+ if (!az.verdict.allow)
653
+ note(az.verdict.message);
654
+ else if (az.cached) {
655
+ // Task 6b — the Microsoft sign-in, before the first turn: asks first,
656
+ // then the browser. Esc / Ctrl+C during it stops it at once (the
657
+ // listener closes). Its own lines (the address to copy when no browser
658
+ // opens) print straight away, never held for after the sign-in.
659
+ const { azureSignInAtStart } = await import('./auth/azure/startup-sign-in.js');
660
+ const { loginOpener } = await import('./util/open-in-browser.js');
661
+ const s = await azureSignInAtStart(az.cached.connection, {
662
+ ask: askOrNo((message) => confirm({ message, default: true })), print: (line) => console.log(line.trimEnd()), open: loginOpener(),
663
+ });
664
+ if (!s.ok)
665
+ note(s.message);
666
+ }
667
+ }
606
668
  // Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
607
669
  // A managed customer is billed in credits at a multiplier we do not
608
670
  // disclose, so the dollar figures (our Anthropic COGS) must never reach
@@ -815,6 +877,41 @@ export async function runRepl(opts) {
815
877
  throw new Error('No API key configured');
816
878
  return key;
817
879
  });
880
+ // Security batch item 1 — licence re-check between turns (byok / ai-hub /
881
+ // local, every 4 hours from the start-up check). Managed: never calls.
882
+ const licenceGate = new LicenceTurnGate({
883
+ mode: cfg.llm.mode,
884
+ proxyUrl: cfg.proxy_url,
885
+ getKey: async () => (await store.get()) ?? null,
886
+ // No start-up check ran (no key yet, e.g. pasted at the prompt below):
887
+ // non-managed checks on the very first turn. Managed never reads it.
888
+ lastCheckAtMs: licenceCheckedAtMs ?? (cfg.llm.mode === 'managed' ? Date.now() : -Infinity),
889
+ ...(cfg.llm.mode === 'azure' ? {
890
+ extra: async () => (await import('./connection/azure-gate.js')).azureGateCheck(),
891
+ // Plan D13 — never a prompt between turns (no inquirer inside the Ink
892
+ // UI): a changed endpoint or tenant is held as pending and the `extra`
893
+ // gate refuses with "…waiting for you to confirm it · start cspeach to
894
+ // review it". Nothing is printed here; the gate's refusal is the notice.
895
+ // So on this 4-hourly path the cache-write-failure line and the ignored
896
+ // env-override lines are dropped (they show again at the next start).
897
+ onRecheck: async () => {
898
+ const { refreshAzureConnection } = await import('./connection/startup.js');
899
+ await refreshAzureConnection({
900
+ proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? null, interactive: false,
901
+ print: () => { }, confirm: async () => false,
902
+ });
903
+ },
904
+ } : {}),
905
+ });
906
+ // Security batch item 3 — send the usage counts still waiting from earlier
907
+ // runs. Non-managed only (a managed seat makes no call); fire-and-forget:
908
+ // never awaited, never throws into start-up.
909
+ if (cfg.llm.mode !== 'managed') {
910
+ try {
911
+ startUsageFlushAtStartup(cfg);
912
+ }
913
+ catch { /* best effort */ }
914
+ }
818
915
  // Task 15 — keep the prompt cache warm while the user is idle between
819
916
  // turns: armed after a turn, disarmed on the next submit and on exit;
820
917
  // `[keepalive] idle_pings` per idle period (managed / BYOK only).
@@ -1148,6 +1245,16 @@ export async function runRepl(opts) {
1148
1245
  // older turns via Haiku so subsequent turns ingest ~5k tokens of
1149
1246
  // context instead of 500k+. Saves \$3-8 per follow-up turn.
1150
1247
  if (trimmed === '/compact') {
1248
+ // Security batch item 1 — /compact makes a model call: gated like a turn.
1249
+ {
1250
+ const g = await licenceGate.beforeTurn();
1251
+ if (!g.allow) {
1252
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1253
+ return;
1254
+ }
1255
+ if (g.line)
1256
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1257
+ }
1151
1258
  const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
1152
1259
  const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
1153
1260
  const { loadConfig } = await import('./config/loader.js');
@@ -1156,7 +1263,7 @@ export async function runRepl(opts) {
1156
1263
  return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
1157
1264
  // model-governance step 2d — compact model resolves env > local >
1158
1265
  // 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' } });
1266
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' }, sessionId: session.id });
1160
1267
  };
1161
1268
  try {
1162
1269
  chunkEmitter.emit('chunk', '\n' + formatInfo('Compacting older turns into a summary…'));
@@ -1219,6 +1326,15 @@ export async function runRepl(opts) {
1219
1326
  chunkEmitter.emit('chunk', '\n' + formatInfo(`Rerouting "${preview}" to /${rr.skill}`));
1220
1327
  if (rr.warning)
1221
1328
  chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
1329
+ {
1330
+ const g = await licenceGate.beforeTurn();
1331
+ if (!g.allow) {
1332
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1333
+ return;
1334
+ }
1335
+ if (g.line)
1336
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1337
+ }
1222
1338
  // Re-review 1 — its own AbortController, wired like the main path, so
1223
1339
  // Ctrl+C and `/exit` stop a rerouted turn the same way.
1224
1340
  const rerouteAbort = new AbortController();
@@ -1576,6 +1692,20 @@ export async function runRepl(opts) {
1576
1692
  session.last_object = extractedObject ?? previousObject ?? null;
1577
1693
  return;
1578
1694
  }
1695
+ // Security batch item 1 — never mid-turn: checked here, before the turn
1696
+ // and before any plan-resume work (run lease, object-list grant,
1697
+ // re-audit, phase context, plan_in_flight). A plan step refused here
1698
+ // never starts; the plan file keeps its state, so `/abap-plan --resume`
1699
+ // continues later.
1700
+ {
1701
+ const g = await licenceGate.beforeTurn();
1702
+ if (!g.allow) {
1703
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1704
+ return;
1705
+ }
1706
+ if (g.line)
1707
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1708
+ }
1579
1709
  // B3 (2026-06-06) — `/abap-plan --resume @plan-file`. Token-free
1580
1710
  // pre-work (load envelope, pick next eligible phase, print the
1581
1711
  // tracker board, inline the phase's rule files) happens in
@@ -1724,7 +1854,7 @@ export async function runRepl(opts) {
1724
1854
  const { beginPlanPhaseContext, makePhaseBoundarySummariser } = await import('./commands/plan-boundary.js');
1725
1855
  await beginPlanPhaseContext({
1726
1856
  session,
1727
- summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg)),
1857
+ summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg), session.id),
1728
1858
  log: (line) => chunkEmitter.emit('chunk', line + '\n'),
1729
1859
  });
1730
1860
  }
@@ -1752,7 +1882,9 @@ export async function runRepl(opts) {
1752
1882
  delegateTo: planResume.nextDelegateTo,
1753
1883
  // model-governance step 2d — the session default now resolves
1754
1884
  // env > local > server > built-in (== cfg.default_model when nothing set).
1755
- defaultModel: resolveModelRole('session_default', cfg),
1885
+ defaultModel: familyOf(resolveModelRole('session_default', cfg), provider),
1886
+ // Azure tenant mode (Task 2) — undefined (today's Sonnet) with no connection table.
1887
+ tierModel: planTierModelFor(getActiveDeployments()),
1756
1888
  });
1757
1889
  if (choice.model) {
1758
1890
  planModelOverride = choice.model;
@@ -1987,6 +2119,9 @@ export async function runRepl(opts) {
1987
2119
  planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
1988
2120
  }
1989
2121
  }
2122
+ // 2026-10-13 — end the stateful SAP session between turns, so a lock
2123
+ // a failed write left behind does not outlive the turn (SM12).
2124
+ await endSapSessionAfterTurn(adt);
1990
2125
  }
1991
2126
  // B3 — persist the plan revision from the resume turn's manifest
1992
2127
  // block (runs even after a turn error: partial assistant content is
@@ -2178,7 +2313,7 @@ export async function runRepl(opts) {
2178
2313
  session,
2179
2314
  config: { ...cfg.compact, auto_threshold_tokens: resolveAutoCompactThreshold(cfg) },
2180
2315
  queued: pendingDispatchAccessor.get() !== null,
2181
- summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg)),
2316
+ summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg), session.id),
2182
2317
  emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
2183
2318
  });
2184
2319
  }
@@ -3074,6 +3209,16 @@ export async function runRepl(opts) {
3074
3209
  // Summarises older turns via Haiku 4.5 so subsequent turns ingest ~5k
3075
3210
  // tokens of context instead of 500k+. Backs up session pre-mutation.
3076
3211
  if (trimmed === '/compact') {
3212
+ // Security batch item 1 — /compact makes a model call: gated like a turn.
3213
+ {
3214
+ const g = await licenceGate.beforeTurn();
3215
+ if (!g.allow) {
3216
+ console.log(g.message);
3217
+ continue;
3218
+ }
3219
+ if (g.line)
3220
+ console.log(formatInfo(g.line).trimEnd());
3221
+ }
3077
3222
  const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
3078
3223
  const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
3079
3224
  const { loadConfig } = await import('./config/loader.js');
@@ -3082,7 +3227,7 @@ export async function runRepl(opts) {
3082
3227
  return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
3083
3228
  // model-governance step 2d — compact model resolves env > local >
3084
3229
  // 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' } });
3230
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' }, sessionId: session.id });
3086
3231
  };
3087
3232
  try {
3088
3233
  console.log('\n' + formatInfo('Compacting older turns into a summary…').trimEnd());
@@ -3154,6 +3299,15 @@ export async function runRepl(opts) {
3154
3299
  console.log(formatInfo(`Rerouting "${preview}" to /${rr.skill}`).trimEnd());
3155
3300
  if (rr.warning)
3156
3301
  console.log(chalk.dim(` (${rr.warning})`));
3302
+ {
3303
+ const g = await licenceGate.beforeTurn();
3304
+ if (!g.allow) {
3305
+ console.log(g.message);
3306
+ continue;
3307
+ }
3308
+ if (g.line)
3309
+ console.log(formatInfo(g.line).trimEnd());
3310
+ }
3157
3311
  // A turn error prints the same stderr block as the normal turn path.
3158
3312
  await runRerouteTurn({
3159
3313
  run: () => runTurn({
@@ -3574,6 +3728,17 @@ export async function runRepl(opts) {
3574
3728
  session.last_object = extractedObject ?? previousObject ?? null;
3575
3729
  continue;
3576
3730
  }
3731
+ // Security batch item 1 — checked before the turn and before any
3732
+ // plan-resume work (classic mirror of the Ink path).
3733
+ {
3734
+ const g = await licenceGate.beforeTurn();
3735
+ if (!g.allow) {
3736
+ console.log(g.message);
3737
+ continue;
3738
+ }
3739
+ if (g.line)
3740
+ console.log(formatInfo(g.line).trimEnd());
3741
+ }
3577
3742
  // B3 (2026-06-06) — `/abap-plan --resume @plan-file` (classic-mode
3578
3743
  // mirror of the Ink path above; see that block for the full design
3579
3744
  // note). Token-free pre-work; terminal states end the turn with no
@@ -3700,7 +3865,7 @@ export async function runRepl(opts) {
3700
3865
  const { beginPlanPhaseContext, makePhaseBoundarySummariser } = await import('./commands/plan-boundary.js');
3701
3866
  await beginPlanPhaseContext({
3702
3867
  session,
3703
- summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg)),
3868
+ summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg), session.id),
3704
3869
  log: (line) => console.log(line),
3705
3870
  });
3706
3871
  }
@@ -3723,7 +3888,9 @@ export async function runRepl(opts) {
3723
3888
  delegateTo: planResume.nextDelegateTo,
3724
3889
  // model-governance step 2d — the session default now resolves
3725
3890
  // env > local > server > built-in (== cfg.default_model when nothing set).
3726
- defaultModel: resolveModelRole('session_default', cfg),
3891
+ defaultModel: familyOf(resolveModelRole('session_default', cfg), provider),
3892
+ // Azure tenant mode (Task 2) — undefined (today's Sonnet) with no connection table.
3893
+ tierModel: planTierModelFor(getActiveDeployments()),
3727
3894
  });
3728
3895
  if (choice.model) {
3729
3896
  planModelOverride = choice.model;
@@ -3954,6 +4121,8 @@ export async function runRepl(opts) {
3954
4121
  planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
3955
4122
  }
3956
4123
  }
4124
+ // 2026-10-13 — end the stateful SAP session between turns (Ink mirror).
4125
+ await endSapSessionAfterTurn(adt);
3957
4126
  }
3958
4127
  // B3 — persist the plan revision from the resume turn's manifest block.
3959
4128
  // FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
@@ -4127,7 +4296,7 @@ export async function runRepl(opts) {
4127
4296
  session,
4128
4297
  config: { ...cfg.compact, auto_threshold_tokens: resolveAutoCompactThreshold(cfg) },
4129
4298
  queued: pendingDispatchAccessor.get() !== null,
4130
- summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg)),
4299
+ summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg), session.id),
4131
4300
  emit: (line) => console.log(line),
4132
4301
  });
4133
4302
  }