@skrr-ai/cli 0.1.28 → 0.1.29

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 (66) hide show
  1. package/dist/base-command.d.ts +15 -0
  2. package/dist/base-command.js +49 -0
  3. package/dist/commands/agents/chat.d.ts +2 -0
  4. package/dist/commands/agents/chat.js +40 -2
  5. package/dist/commands/balance/show.d.ts +2 -3
  6. package/dist/commands/balance/show.js +2 -3
  7. package/dist/commands/balance/usage.d.ts +3 -11
  8. package/dist/commands/balance/usage.js +19 -72
  9. package/dist/commands/code/index.d.ts +4 -0
  10. package/dist/commands/code/index.js +9 -0
  11. package/dist/commands/code/install.d.ts +10 -2
  12. package/dist/commands/code/install.js +38 -28
  13. package/dist/commands/harnesses/leases/show.js +7 -4
  14. package/dist/commands/inbox/index.d.ts +15 -0
  15. package/dist/commands/inbox/index.js +52 -20
  16. package/dist/commands/instructions/install.d.ts +12 -0
  17. package/dist/commands/instructions/install.js +59 -14
  18. package/dist/commands/login.js +6 -0
  19. package/dist/commands/machines/dedicated/attach.js +1 -1
  20. package/dist/commands/machines/dedicated/cp.d.ts +1 -0
  21. package/dist/commands/machines/dedicated/cp.js +63 -9
  22. package/dist/commands/machines/dedicated/create.d.ts +1 -1
  23. package/dist/commands/machines/dedicated/create.js +7 -2
  24. package/dist/commands/machines/dedicated/exec.d.ts +23 -1
  25. package/dist/commands/machines/dedicated/exec.js +67 -7
  26. package/dist/commands/machines/dedicated/index.js +2 -0
  27. package/dist/commands/machines/dedicated/restore.d.ts +6 -0
  28. package/dist/commands/machines/dedicated/restore.js +7 -1
  29. package/dist/commands/machines/dedicated/sign-in.d.ts +4 -3
  30. package/dist/commands/machines/dedicated/sign-in.js +4 -3
  31. package/dist/commands/machines/dedicated/terminal.js +1 -1
  32. package/dist/commands/machines/dedicated/update-image.d.ts +15 -0
  33. package/dist/commands/machines/dedicated/update-image.js +38 -0
  34. package/dist/lib/balance.d.ts +2 -2
  35. package/dist/lib/balance.js +6 -4
  36. package/dist/lib/daemon-target.d.ts +103 -0
  37. package/dist/lib/daemon-target.js +110 -0
  38. package/dist/lib/dedicated-copy.d.ts +92 -2
  39. package/dist/lib/dedicated-copy.js +223 -18
  40. package/dist/lib/dedicated-lease-command.d.ts +7 -1
  41. package/dist/lib/dedicated-lease-command.js +16 -3
  42. package/dist/lib/dedicated-machines.d.ts +130 -8
  43. package/dist/lib/dedicated-machines.js +274 -15
  44. package/dist/lib/dedicated-terminal.d.ts +5 -25
  45. package/dist/lib/dedicated-terminal.js +45 -73
  46. package/dist/lib/dedicated-wait.d.ts +10 -0
  47. package/dist/lib/dedicated-wait.js +52 -0
  48. package/dist/lib/device-code.d.ts +12 -1
  49. package/dist/lib/device-code.js +44 -9
  50. package/dist/lib/harnesses.d.ts +13 -0
  51. package/dist/lib/harnesses.js +24 -0
  52. package/dist/lib/login.js +8 -7
  53. package/dist/lib/sky-code-broker.d.ts +46 -5
  54. package/dist/lib/sky-code-broker.js +96 -26
  55. package/dist/lib/sky-code.d.ts +33 -0
  56. package/dist/lib/sky-code.js +45 -7
  57. package/dist/lib/task-instruction-offer.js +12 -0
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +67 -1
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +124 -12
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +67 -1
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +123 -11
  62. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  63. package/dist/node_modules/@skrr-ai/data-provider/index.js +3061 -2876
  64. package/dist/node_modules/@skrr-ai/data-provider/package.json +1 -1
  65. package/oclif.manifest.json +2930 -2828
  66. package/package.json +1 -1
@@ -32,8 +32,26 @@ function progressOf(lease) {
32
32
  * reading that cannot turn into the goal without someone acting again; anything
33
33
  * that might still settle is `pending`, and the caller's timeout decides how long
34
34
  * that is worth watching.
35
+ *
36
+ * Reaching the goal is not the end of the wait while the lease's operation is
37
+ * still in progress: `--wait` promises the operation has FINISHED, and the
38
+ * server refuses the next one until it has. A create read `ready` the moment its
39
+ * guest registered and returned, and the restart that followed was refused for
40
+ * minutes (OSK-8775). A server that does not report the operation is judged on
41
+ * the goal alone, as before.
35
42
  */
36
43
  function evaluateDedicatedWait(goal, lease) {
44
+ const verdict = evaluateDedicatedWaitGoal(goal, lease);
45
+ if (verdict.status !== 'done' || lease?.operation?.status !== 'in_progress') {
46
+ return verdict;
47
+ }
48
+ const action = lease.operation.action;
49
+ return {
50
+ status: 'pending',
51
+ progress: `${progressOf(lease)} · ${action ? `${action} ` : 'operation '}still finishing`,
52
+ };
53
+ }
54
+ function evaluateDedicatedWaitGoal(goal, lease) {
37
55
  if (!lease) {
38
56
  return { status: 'failed', reason: 'the lease is no longer visible' };
39
57
  }
@@ -89,6 +107,40 @@ function evaluateDedicatedWait(goal, lease) {
89
107
  reason: `the snapshot ended (${state}) without recording a new recovery point`,
90
108
  };
91
109
  }
110
+ case 'image_updated': {
111
+ // Running says nothing: a machine returned to its previous image runs too.
112
+ // The update's own record does, once the platform has judged it — and only
113
+ // an update that started after the request (the 202 carries the one before).
114
+ if (DEAD_STATES.has(state) || state === 'archived' || state === 'terminating') {
115
+ return { status: 'failed', reason: `the machine is ${state}` };
116
+ }
117
+ const last = lease.health?.image?.lastUpdate;
118
+ const fresh = Boolean(last?.startedAt) && last?.startedAt !== goal.previousUpdateStartedAt;
119
+ if (fresh && last?.status === 'succeeded' && RUNNING_STATES.has(state)) {
120
+ return { status: 'done' };
121
+ }
122
+ // Nothing was left to move by the time the update ran (the image it was
123
+ // asked for stopped being newer): the machine is back, as it was.
124
+ if (!fresh && RUNNING_STATES.has(state) && lease.health?.image?.updateAvailable === false) {
125
+ return { status: 'done' };
126
+ }
127
+ if (fresh && (last?.status === 'rolled_back' || last?.status === 'failed')) {
128
+ return {
129
+ status: 'failed',
130
+ reason: last.status === 'rolled_back'
131
+ ? 'the current image did not come back healthy; the machine is back on its previous image, files kept'
132
+ : 'the update did not complete on either image; files are kept',
133
+ };
134
+ }
135
+ let moving = 'update requested';
136
+ if (fresh) {
137
+ moving =
138
+ last?.status === 'rolling_back'
139
+ ? 'returning to the previous image'
140
+ : 'moving onto the current image';
141
+ }
142
+ return { status: 'pending', progress: `${progressOf(lease)} · ${moving}` };
143
+ }
92
144
  case 'grown': {
93
145
  const size = lease.resources?.storageGb;
94
146
  if (typeof size === 'number' && size >= goal.storageGb)
@@ -38,9 +38,20 @@ export interface DeviceCodePollerOptions {
38
38
  onStart?: (start: DeviceCodeStart) => void;
39
39
  /**
40
40
  * Called on every poll tick before the HTTP call. Returns false to
41
- * cancel — used so SIGINT can break the loop without leaking timers.
41
+ * cancel.
42
42
  */
43
43
  shouldContinue?: () => boolean;
44
+ /**
45
+ * Cancels the poll at once — the request in flight and the wait between
46
+ * ticks — with DeviceCodeLoginCancelledError. What Ctrl-C uses.
47
+ */
48
+ signal?: AbortSignal;
49
+ }
50
+ /** The person stopped waiting for approval. Exits 130, as the Ctrl-C that caused it would. */
51
+ export declare class DeviceCodeLoginCancelledError extends Error {
52
+ readonly code = "LOGIN_CANCELLED";
53
+ readonly exitCode = 130;
54
+ constructor();
44
55
  }
45
56
  /**
46
57
  * Issue a device code from the server.
@@ -21,12 +21,23 @@
21
21
  * → 404 { error: 'Device code expired or not found' }
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.DeviceCodeLoginCancelledError = void 0;
24
25
  exports.startDeviceCode = startDeviceCode;
25
26
  exports.pollDeviceCode = pollDeviceCode;
26
27
  exports.revokeDeviceRefreshToken = revokeDeviceRefreshToken;
27
28
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
28
29
  /** Server-side TTL is 300s; we add a small safety margin for clock drift. */
29
30
  const DEFAULT_OVERALL_TIMEOUT_MS = 320_000;
31
+ /** The person stopped waiting for approval. Exits 130, as the Ctrl-C that caused it would. */
32
+ class DeviceCodeLoginCancelledError extends Error {
33
+ code = 'LOGIN_CANCELLED';
34
+ exitCode = 130;
35
+ constructor() {
36
+ super('Login cancelled before the device code was approved; nothing was saved.');
37
+ this.name = 'DeviceCodeLoginCancelledError';
38
+ }
39
+ }
40
+ exports.DeviceCodeLoginCancelledError = DeviceCodeLoginCancelledError;
30
41
  /**
31
42
  * Issue a device code from the server.
32
43
  *
@@ -69,18 +80,21 @@ async function pollDeviceCode(baseURL, code, opts = {}) {
69
80
  const interval = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
70
81
  const timeout = opts.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS;
71
82
  const shouldContinue = opts.shouldContinue ?? (() => true);
83
+ const { signal } = opts;
72
84
  const deadline = Date.now() + timeout;
73
85
  while (Date.now() < deadline) {
74
- if (!shouldContinue()) {
75
- throw new Error('Device-code login cancelled');
86
+ if (signal?.aborted || !shouldContinue()) {
87
+ throw new DeviceCodeLoginCancelledError();
76
88
  }
77
89
  let res;
78
90
  try {
79
- res = await fetch(`${baseURL.replace(/\/$/, '')}/api/daemons/device-code/${encodeURIComponent(code)}/status`, { method: 'GET', headers: { Accept: 'application/json' } });
91
+ res = await fetch(`${baseURL.replace(/\/$/, '')}/api/daemons/device-code/${encodeURIComponent(code)}/status`, { method: 'GET', headers: { Accept: 'application/json' }, ...(signal ? { signal } : {}) });
80
92
  }
81
93
  catch {
94
+ if (signal?.aborted)
95
+ throw new DeviceCodeLoginCancelledError();
82
96
  // Transient network error — wait and retry.
83
- await sleep(interval);
97
+ await sleep(interval, signal);
84
98
  continue;
85
99
  }
86
100
  if (res.status === 404) {
@@ -88,21 +102,29 @@ async function pollDeviceCode(baseURL, code, opts = {}) {
88
102
  }
89
103
  if (res.status === 429) {
90
104
  // Server is asking us to slow down. Double the wait this round.
91
- await sleep(interval * 2);
105
+ await sleep(interval * 2, signal);
92
106
  continue;
93
107
  }
94
108
  if (!res.ok) {
95
109
  const body = await res.text().catch(() => '');
96
110
  throw new Error(`Poll failed: HTTP ${res.status} ${body.slice(0, 200)}`);
97
111
  }
98
- const data = (await res.json());
112
+ let data;
113
+ try {
114
+ data = (await res.json());
115
+ }
116
+ catch (err) {
117
+ if (signal?.aborted)
118
+ throw new DeviceCodeLoginCancelledError();
119
+ throw err;
120
+ }
99
121
  if (data.status === 'approved') {
100
122
  if (!('token' in data) || !data.token) {
101
123
  throw new Error('Server reported approval without a token');
102
124
  }
103
125
  return data;
104
126
  }
105
- await sleep(interval);
127
+ await sleep(interval, signal);
106
128
  }
107
129
  throw new Error('Device-code login timed out waiting for approval');
108
130
  }
@@ -133,13 +155,26 @@ async function revokeDeviceRefreshToken(baseURL, refreshToken) {
133
155
  clearTimeout(timer);
134
156
  }
135
157
  }
136
- function sleep(ms) {
158
+ function sleep(ms, signal) {
137
159
  // Do NOT unref — pollDeviceCode is the only thing keeping the event
138
160
  // loop alive during device-code login, so an unref'd timer lets Node
139
161
  // exit cleanly mid-poll (process exits with code 0 after the first
140
162
  // sleep, never reaching approval). Mirrors the same intentional
141
163
  // non-unref in loginLocalhost.ts:419.
142
164
  return new Promise((resolve) => {
143
- setTimeout(resolve, ms);
165
+ if (signal?.aborted) {
166
+ resolve();
167
+ return;
168
+ }
169
+ const timer = setTimeout(() => {
170
+ signal?.removeEventListener('abort', wake);
171
+ resolve();
172
+ }, ms);
173
+ // A cancelled login wakes now; the loop's next check throws.
174
+ const wake = () => {
175
+ clearTimeout(timer);
176
+ resolve();
177
+ };
178
+ signal?.addEventListener('abort', wake, { once: true });
144
179
  });
145
180
  }
@@ -57,6 +57,19 @@ export interface Harness {
57
57
  */
58
58
  metadata?: Record<string, unknown>;
59
59
  }
60
+ /**
61
+ * A machine's health reason codes as the sentences a person reads, from the
62
+ * copy map the web renders too (`describeMachineHealthReasons`,
63
+ * `@skrr-ai/data-provider`). Every CLI surface that explains why a lease cannot
64
+ * take work goes through this: `machines dedicated` and `harnesses leases show`.
65
+ *
66
+ * With the lease `state`, reasons that only follow from a lease at rest or in
67
+ * transition are left out: an archived machine is archived, and that it has not
68
+ * checked in is the same fact. A code this build has no words for keeps its code
69
+ * in parentheses, so the fallback sentence can still be traced. Empty when there
70
+ * is nothing to say. Scripts read the codes from `--json`, never from this.
71
+ */
72
+ export declare function describeHealthReasonSentences(reasons: unknown, state?: unknown): string;
60
73
  /**
61
74
  * One open lease, as `/api/machines/leases` actually returns it.
62
75
  *
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HARNESS_LEASE_ACTIONS = void 0;
4
+ exports.describeHealthReasonSentences = describeHealthReasonSentences;
4
5
  exports.listHarnesses = listHarnesses;
5
6
  exports.getHarness = getHarness;
6
7
  exports.getHarnessModels = getHarnessModels;
@@ -22,7 +23,30 @@ exports.getHarnessLease = getHarnessLease;
22
23
  exports.releaseHarnessLease = releaseHarnessLease;
23
24
  exports.performHarnessLeaseAction = performHarnessLeaseAction;
24
25
  exports.setHarnessLeaseBilling = setHarnessLeaseBilling;
26
+ const data_provider_1 = require("@skrr-ai/data-provider");
25
27
  const api_fetch_1 = require("./api-fetch");
28
+ /**
29
+ * A machine's health reason codes as the sentences a person reads, from the
30
+ * copy map the web renders too (`describeMachineHealthReasons`,
31
+ * `@skrr-ai/data-provider`). Every CLI surface that explains why a lease cannot
32
+ * take work goes through this: `machines dedicated` and `harnesses leases show`.
33
+ *
34
+ * With the lease `state`, reasons that only follow from a lease at rest or in
35
+ * transition are left out: an archived machine is archived, and that it has not
36
+ * checked in is the same fact. A code this build has no words for keeps its code
37
+ * in parentheses, so the fallback sentence can still be traced. Empty when there
38
+ * is nothing to say. Scripts read the codes from `--json`, never from this.
39
+ */
40
+ function describeHealthReasonSentences(reasons, state) {
41
+ const { lines } = (0, data_provider_1.describeMachineHealthReasons)(reasons, {
42
+ state: typeof state === 'string' ? state : undefined,
43
+ });
44
+ return lines
45
+ .map((line) => line.known || line.codes.length === 0
46
+ ? line.sentence
47
+ : `${line.sentence.replace(/\.$/, '')} (${line.codes.join(', ')}).`)
48
+ .join(' ');
49
+ }
26
50
  /**
27
51
  * Every action `POST /api/machines/leases/:id/actions/:action` accepts —
28
52
  * `MACHINE_LEASE_ACTIONS` in MachineLeaseService. Which of them a given lease
package/dist/lib/login.js CHANGED
@@ -402,16 +402,17 @@ async function runDeviceCode(serverUrl, cliId, reason, ssh, errorMessage) {
402
402
  openBrowserPlatform(start.verificationUrl);
403
403
  }
404
404
  console.log(' Waiting for approval — this command will continue automatically.');
405
- let cancelled = false;
406
- const onSigint = () => {
407
- cancelled = true;
408
- };
405
+ // Ctrl-C stops the wait at once — the status request in flight and the pause
406
+ // between polls — and the command exits 130 (DeviceCodeLoginCancelledError).
407
+ // This listener only started running with OSK-8708: auth-core's lock handler
408
+ // used to exit the process first. A flag checked on the next tick would now
409
+ // leave Ctrl-C waiting out a poll and ending as an ordinary error.
410
+ const cancel = new AbortController();
411
+ const onSigint = () => cancel.abort();
409
412
  process.on('SIGINT', onSigint);
410
413
  let approved;
411
414
  try {
412
- approved = await (0, device_code_1.pollDeviceCode)(serverUrl, start.code, {
413
- shouldContinue: () => !cancelled,
414
- });
415
+ approved = await (0, device_code_1.pollDeviceCode)(serverUrl, start.code, { signal: cancel.signal });
415
416
  }
416
417
  finally {
417
418
  process.off('SIGINT', onSigint);
@@ -50,6 +50,12 @@
50
50
  * refusals already carry codes (`model-denied`, `budget-exhausted`,
51
51
  * `policy-unavailable`, `session-lease-exists`, …); this module forwards them
52
52
  * verbatim and adds its own for the local preconditions.
53
+ *
54
+ * The code is also what decides whether there is a fallback at all. A refusal
55
+ * that is a DECISION about the account (`plan-allowance-exhausted`,
56
+ * `balance-exhausted`, …) stops the run instead (OSK-8822): switching to the
57
+ * user's own key because their plan said no is the silent mode switch I4
58
+ * forbids, and without a key it only starts an engine that cannot run.
53
59
  */
54
60
  import { buildManagedInferenceRelayCredential, startManagedInferenceBroker, type RenewalStopReason } from '@skrr-ai/inference-broker';
55
61
  import { type ApiFetchOptions } from './api-fetch';
@@ -237,10 +243,17 @@ export interface ManagedEngineHooksInput {
237
243
  * engine binary FIRST and refuses a missing one before `prepare` runs. Acquiring
238
244
  * earlier would mint a lease and open a socket for an engine that never starts.
239
245
  *
240
- * `prepare` cannot throw. `execEngine` awaits it OUTSIDE its try/finally, so a
241
- * throw here would abort the run and skip `cleanup` — turning "we could not get
242
- * you a managed session" into the hard failure invariant I4 forbids. Every
243
- * failure is therefore caught, said once, and answered with `{}` (BYOK).
246
+ * `prepare` rejects for exactly one reason. Any rejection aborts the run, so a
247
+ * failure that merely means "we could not get you a managed session" must not
248
+ * reject — that would be the hard failure invariant I4 forbids. Those are caught,
249
+ * said once, and answered with `{}` (BYOK).
250
+ *
251
+ * The one reason is a refusal that is a DECISION about the account (OSK-8822).
252
+ * There, starting the engine is the wrong answer whether or not a provider key
253
+ * exists — with one it silently moves the spend to a different billing boundary
254
+ * because the plan said no, and without one it starts an engine whose first
255
+ * request fails — so it is said once and rejected with `EngineStartRefusedError`,
256
+ * which `execEngine` answers by not spawning at all.
244
257
  */
245
258
  export declare function createManagedEngineHooks(input: ManagedEngineHooksInput): ManagedEngineHooks;
246
259
  /**
@@ -269,5 +282,33 @@ export declare function createManagedEngineHooks(input: ManagedEngineHooksInput)
269
282
  * Returns null when there is nothing worth saying.
270
283
  */
271
284
  export declare function managedRenewalStoppedLine(reason: RenewalStopReason, detail?: string): string | null;
272
- export declare function managedFallbackLine(err: unknown, env?: NodeJS.ProcessEnv): string;
285
+ export type ManagedAcquisitionFailure =
286
+ /** The server decided about the account. The engine must not start. */
287
+ {
288
+ action: 'refuse';
289
+ code: string;
290
+ line: string;
291
+ }
292
+ /** Nothing was decided about the account. The run continues without a broker. */
293
+ | {
294
+ action: 'fallback';
295
+ code?: string;
296
+ line: string;
297
+ };
298
+ /**
299
+ * What a failed acquisition does next, and the ONE line that says so.
300
+ *
301
+ * One function for both because they must never disagree: a line promising
302
+ * "continuing with your own provider credentials" over a run that was stopped,
303
+ * or "did not start the engine" over one that started, is the confusion between
304
+ * managed, BYOK and offline that I4 forbids.
305
+ *
306
+ * The line names the machine-readable code as well as the sentence: the codes
307
+ * are the server's typed refusals (`model-denied`, `budget-exhausted`,
308
+ * `policy-unavailable` …) and "which of those happened" is the first thing
309
+ * support asks. It ends by saying what is about to happen instead, reading the
310
+ * environment for whether "your own key" is a thing that exists here, because
311
+ * naming the wrong one of managed / BYOK / offline is the same failure.
312
+ */
313
+ export declare function managedAcquisitionFailure(err: unknown, env?: NodeJS.ProcessEnv): ManagedAcquisitionFailure;
273
314
  export {};
@@ -51,6 +51,12 @@
51
51
  * refusals already carry codes (`model-denied`, `budget-exhausted`,
52
52
  * `policy-unavailable`, `session-lease-exists`, …); this module forwards them
53
53
  * verbatim and adds its own for the local preconditions.
54
+ *
55
+ * The code is also what decides whether there is a fallback at all. A refusal
56
+ * that is a DECISION about the account (`plan-allowance-exhausted`,
57
+ * `balance-exhausted`, …) stops the run instead (OSK-8822): switching to the
58
+ * user's own key because their plan said no is the silent mode switch I4
59
+ * forbids, and without a key it only starts an engine that cannot run.
54
60
  */
55
61
  Object.defineProperty(exports, "__esModule", { value: true });
56
62
  exports.ManagedAcquisitionError = void 0;
@@ -59,7 +65,7 @@ exports.hasLocalCredential = hasLocalCredential;
59
65
  exports.managedLeaseClockLine = managedLeaseClockLine;
60
66
  exports.createManagedEngineHooks = createManagedEngineHooks;
61
67
  exports.managedRenewalStoppedLine = managedRenewalStoppedLine;
62
- exports.managedFallbackLine = managedFallbackLine;
68
+ exports.managedAcquisitionFailure = managedAcquisitionFailure;
63
69
  const node_crypto_1 = require("node:crypto");
64
70
  const auth_core_1 = require("@skrr-ai/auth-core");
65
71
  const inference_broker_1 = require("@skrr-ai/inference-broker");
@@ -970,10 +976,17 @@ function fallbackTail(env) {
970
976
  * engine binary FIRST and refuses a missing one before `prepare` runs. Acquiring
971
977
  * earlier would mint a lease and open a socket for an engine that never starts.
972
978
  *
973
- * `prepare` cannot throw. `execEngine` awaits it OUTSIDE its try/finally, so a
974
- * throw here would abort the run and skip `cleanup` — turning "we could not get
975
- * you a managed session" into the hard failure invariant I4 forbids. Every
976
- * failure is therefore caught, said once, and answered with `{}` (BYOK).
979
+ * `prepare` rejects for exactly one reason. Any rejection aborts the run, so a
980
+ * failure that merely means "we could not get you a managed session" must not
981
+ * reject — that would be the hard failure invariant I4 forbids. Those are caught,
982
+ * said once, and answered with `{}` (BYOK).
983
+ *
984
+ * The one reason is a refusal that is a DECISION about the account (OSK-8822).
985
+ * There, starting the engine is the wrong answer whether or not a provider key
986
+ * exists — with one it silently moves the spend to a different billing boundary
987
+ * because the plan said no, and without one it starts an engine whose first
988
+ * request fails — so it is said once and rejected with `EngineStartRefusedError`,
989
+ * which `execEngine` answers by not spawning at all.
977
990
  */
978
991
  function createManagedEngineHooks(input) {
979
992
  const env = input.env ?? process.env;
@@ -1043,7 +1056,13 @@ function createManagedEngineHooks(input) {
1043
1056
  return handle.env;
1044
1057
  }
1045
1058
  catch (err) {
1046
- log(managedFallbackLine(err, env));
1059
+ // ONE classification decides both the sentence and the action, so the line
1060
+ // cannot promise a fallback the run does not take, or the reverse.
1061
+ const failure = managedAcquisitionFailure(err, env);
1062
+ log(failure.line);
1063
+ if (failure.action === 'refuse') {
1064
+ throw new sky_code_1.EngineStartRefusedError(failure.code, failure.line);
1065
+ }
1047
1066
  return {};
1048
1067
  }
1049
1068
  },
@@ -1170,29 +1189,33 @@ function managedRenewalStoppedLine(reason, detail) {
1170
1189
  'your own key.');
1171
1190
  }
1172
1191
  }
1173
- /**
1174
- * The ONE line a failed acquisition prints.
1175
- *
1176
- * Names the machine-readable code as well as the sentence: the codes are the
1177
- * server's typed refusals (`model-denied`, `budget-exhausted`, `policy-unavailable`
1178
- * …) and "which of those happened" is the first thing support asks. Ends by saying
1179
- * what is about to happen instead, because a user who does not know they switched
1180
- * to their own key is the failure mode I4 exists to prevent — and by reading the
1181
- * environment for whether "your own key" is a thing that exists here, because
1182
- * naming the wrong one of managed / BYOK / offline is the same failure.
1183
- */
1184
1192
  /**
1185
1193
  * Refusals that are a DECISION about the account, not the service being down.
1186
1194
  *
1187
- * The server answers these with 402 and a sentence that already says what is
1188
- * wrong and what to do — 1408f64ab4 changed them from `503
1195
+ * The server answers these with a status that is not a 5xx (402 where money or
1196
+ * an allowance is the remedy, 403 for a model the plan excludes, 429 for the
1197
+ * plan's concurrent-session ceiling) and a sentence that already says what is
1198
+ * wrong and what to do — 1408f64ab4 and OSK-8780 moved them off `503
1189
1199
  * admission-unavailable` precisely so a short account would stop reading as an
1190
1200
  * outage. Prefixing "Managed inference unavailable" put the outage back on:
1191
1201
  * the reader was told the platform was broken and, underneath, that their
1192
1202
  * balance was empty, and the first sentence is the one people act on.
1193
1203
  *
1194
- * Everything else keeps the cautious wording, which is right for something this
1195
- * CLI does not recognise.
1204
+ * Mirrored from `sessionInferenceRelay.js`, which declares them inline: no
1205
+ * package owns this vocabulary, so there is nothing to import it from. A new
1206
+ * account-decision code the server adds and this set lacks degrades to the
1207
+ * cautious wording AND the fallback — exactly what OSK-8822 reported for the four
1208
+ * plan codes — so it belongs here in the same change.
1209
+ *
1210
+ * Membership STOPS the run (OSK-8822), not only words it: a decision about the
1211
+ * account is not answered by starting the engine on some other credential.
1212
+ * Everything else keeps the cautious wording and the fallback, which is right
1213
+ * for something this CLI does not recognise.
1214
+ *
1215
+ * `plan-concurrency-exhausted` clears on its own and is here anyway: the server
1216
+ * READ the plan and named the remedy (end another session), and falling back
1217
+ * would move this session's spend onto the user's own key because a different
1218
+ * session holds the slot.
1196
1219
  *
1197
1220
  * `budget-exhausted` is deliberately NOT here. It reports a lease's budget or
1198
1221
  * concurrency slot being held — including the case where a user who quit and
@@ -1201,14 +1224,61 @@ function managedRenewalStoppedLine(reason, detail) {
1201
1224
  * the answer. Widening this set to a code whose semantics are inferred rather
1202
1225
  * than read is how honest copy drifts back into vague copy.
1203
1226
  */
1204
- const ACCOUNT_DECISION_CODES = new Set(['balance-exhausted', 'billing-account-missing']);
1205
- function managedFallbackLine(err, env = process.env) {
1227
+ const ACCOUNT_DECISION_CODES = new Set([
1228
+ 'balance-exhausted',
1229
+ 'billing-account-missing',
1230
+ 'plan-allowance-exhausted',
1231
+ 'plan-model-denied',
1232
+ 'plan-window-exhausted',
1233
+ 'plan-concurrency-exhausted',
1234
+ ]);
1235
+ /**
1236
+ * What a failed acquisition does next, and the ONE line that says so.
1237
+ *
1238
+ * One function for both because they must never disagree: a line promising
1239
+ * "continuing with your own provider credentials" over a run that was stopped,
1240
+ * or "did not start the engine" over one that started, is the confusion between
1241
+ * managed, BYOK and offline that I4 forbids.
1242
+ *
1243
+ * The line names the machine-readable code as well as the sentence: the codes
1244
+ * are the server's typed refusals (`model-denied`, `budget-exhausted`,
1245
+ * `policy-unavailable` …) and "which of those happened" is the first thing
1246
+ * support asks. It ends by saying what is about to happen instead, reading the
1247
+ * environment for whether "your own key" is a thing that exists here, because
1248
+ * naming the wrong one of managed / BYOK / offline is the same failure.
1249
+ */
1250
+ function managedAcquisitionFailure(err, env = process.env) {
1206
1251
  const code = errorCodeOf(err);
1207
1252
  if (code && ACCOUNT_DECISION_CODES.has(code)) {
1208
1253
  // No "unavailable": nothing is down. The server's sentence names the pot
1209
1254
  // and the remedy, so it leads.
1210
- return `Managed inference was refused (${code}): ${describe(err)}. ${fallbackTail(env)}`;
1255
+ return {
1256
+ action: 'refuse',
1257
+ code,
1258
+ line: `Managed inference was refused (${code}): ${describe(err)}. ${refusalTail(env)}`,
1259
+ };
1260
+ }
1261
+ return {
1262
+ action: 'fallback',
1263
+ ...(code ? { code } : {}),
1264
+ line: `Managed inference unavailable${code ? ` (${code})` : ''}: ${describe(err)}. ` +
1265
+ fallbackTail(env),
1266
+ };
1267
+ }
1268
+ /**
1269
+ * What a refused run says where a fallback would have said where it was going.
1270
+ *
1271
+ * The remedy is the server's sentence, which comes first; this adds only what the
1272
+ * server cannot know. That the engine did not start, so a reader who next sees a
1273
+ * shell prompt does not wonder whether the task ran. And, when this machine HAS a
1274
+ * provider key, how to spend it deliberately — the managed opt-out, which makes
1275
+ * BYOK a declared mode instead of something a plan refusal switched on. Never
1276
+ * offered where there is no key to spend.
1277
+ */
1278
+ function refusalTail(env) {
1279
+ if ((0, sky_code_1.hasProviderCredential)(env)) {
1280
+ return ('`skrr code` did not start the engine; to run on your own provider key instead, ' +
1281
+ `set ${sky_code_managed_1.MANAGED_ENV_FLAG}=0.`);
1211
1282
  }
1212
- return (`Managed inference unavailable${code ? ` (${code})` : ''}: ${describe(err)}. ` +
1213
- fallbackTail(env));
1283
+ return '`skrr code` did not start the engine.';
1214
1284
  }
@@ -169,6 +169,36 @@ export declare function notInstalledMessage(env?: NodeJS.ProcessEnv): string;
169
169
  export interface EngineExitOutcome {
170
170
  interrupted: boolean;
171
171
  }
172
+ /**
173
+ * OSK-8822 — the run was REFUSED before the engine started.
174
+ *
175
+ * `prepare` used to have exactly one way to answer: an env map, where `{}` means
176
+ * "run on the operator's own credentials". So when the server refused the
177
+ * ACCOUNT — a plan allowance used up, a model the plan does not include — the
178
+ * refusal could only become a BYOK run, and the engine started with no
179
+ * credential and failed a second time with a generic error, exiting with the
180
+ * same `1` as a task that ran and failed.
181
+ *
182
+ * A distinct type rather than a sentinel env, so the one outcome that must stop
183
+ * the run cannot be produced by accident and cannot be mistaken for an ordinary
184
+ * `prepare` failure. Whoever throws it has already told the user why: it is a
185
+ * control signal, and its message is for logs, not for printing a second time.
186
+ */
187
+ export declare class EngineStartRefusedError extends Error {
188
+ /** The refusal's machine-readable code, e.g. `plan-allowance-exhausted`. */
189
+ readonly code: string;
190
+ constructor(code: string, message: string);
191
+ }
192
+ /**
193
+ * The exit code of a refused run: sysexits' `EX_NOPERM`, the one that means
194
+ * "not permitted" rather than "not available".
195
+ *
196
+ * CLI-owned, like `127` for a missing engine, because nothing ran to report one.
197
+ * It has to differ from `1`, which is what an engine that started and failed
198
+ * reports — the production case this exists for exited `1`, and CI could not tell
199
+ * "your plan refused this" from "the task failed".
200
+ */
201
+ export declare const ENGINE_START_REFUSED_EXIT_CODE = 77;
172
202
  export interface EngineSpawnOptions {
173
203
  /**
174
204
  * OSK-3892 — run before the engine is spawned, to acquire whatever the child
@@ -177,6 +207,9 @@ export interface EngineSpawnOptions {
177
207
  * Async, which is why this function is no longer a bare `new Promise` executor:
178
208
  * that shape cannot `await`, and a managed session has to reach the API before
179
209
  * the engine exists.
210
+ *
211
+ * Rejecting with {@link EngineStartRefusedError} refuses the run: the engine is
212
+ * never spawned, `cleanup` still runs, and the error reaches the caller.
180
213
  */
181
214
  prepare?: () => Promise<Record<string, string>>;
182
215
  /** Always run, on every exit path. Tear down anything `prepare` created. */