@skrr-ai/cli 0.1.28 → 0.1.30

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 +1429 -1327
  66. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEDICATED_FILE_MAX_BYTES = exports.DEDICATED_FILE_CHUNK_BYTES = exports.DEDICATED_EXEC_DEFAULT_TIMEOUT_MS = exports.DEDICATED_EXEC_MAX_TIMEOUT_MS = exports.DEDICATED_MACHINE_PRODUCT = exports.DEDICATED_LEASE_ACTIONS = void 0;
4
+ exports.describeDedicatedImageStanding = describeDedicatedImageStanding;
4
5
  exports.dedicatedRuntimeCreateInput = dedicatedRuntimeCreateInput;
5
6
  exports.isDedicatedRepositoryRef = isDedicatedRepositoryRef;
6
7
  exports.createDedicatedRuntime = createDedicatedRuntime;
@@ -25,23 +26,59 @@ exports.formatUsdCents = formatUsdCents;
25
26
  exports.dedicatedSpendSummary = dedicatedSpendSummary;
26
27
  exports.describeDedicatedDeletion = describeDedicatedDeletion;
27
28
  exports.describeDedicatedLease = describeDedicatedLease;
29
+ exports.formatDedicatedDeadline = formatDedicatedDeadline;
30
+ exports.dedicatedDeletionLines = dedicatedDeletionLines;
28
31
  exports.dedicatedLeaseHarnesses = dedicatedLeaseHarnesses;
29
32
  exports.dedicatedLeaseIdForDaemon = dedicatedLeaseIdForDaemon;
30
33
  exports.dedicatedTerminalHarness = dedicatedTerminalHarness;
31
34
  exports.selectDedicatedAttachHarness = selectDedicatedAttachHarness;
32
35
  exports.dedicatedCatalogFromProducts = dedicatedCatalogFromProducts;
33
36
  exports.dedicatedRuntimeApiErrorCode = dedicatedRuntimeApiErrorCode;
37
+ exports.dedicatedRuntimeApiErrorDetails = dedicatedRuntimeApiErrorDetails;
34
38
  exports.formatDedicatedRuntimeApiError = formatDedicatedRuntimeApiError;
35
39
  const api_fetch_1 = require("./api-fetch");
36
40
  const edge_error_page_1 = require("./edge-error-page");
41
+ const harnesses_1 = require("./harnesses");
37
42
  exports.DEDICATED_LEASE_ACTIONS = [
38
43
  'start',
39
44
  'stop',
40
45
  'restart',
46
+ 'update-image',
41
47
  'snapshot',
42
48
  'health-check',
43
49
  ];
44
50
  exports.DEDICATED_MACHINE_PRODUCT = 'dedicated-machine';
51
+ /** One line saying where a machine's image stands and what the owner can do. */
52
+ function describeDedicatedImageStanding(image, leaseId) {
53
+ const last = image.lastUpdate;
54
+ if (last?.status === 'applying') {
55
+ return `moving onto the current image${last.startedAt ? ` since ${last.startedAt}` : ''}; files are kept`;
56
+ }
57
+ if (last?.status === 'rolling_back') {
58
+ return 'the current image did not come back healthy; returning to the previous image, files kept';
59
+ }
60
+ const parts = [];
61
+ if (image.updateAvailable) {
62
+ parts.push(`update available${image.updateBy ? `, required by ${image.updateBy}` : ''} — \`skrr machines dedicated update-image ${leaseId || '<lease-id>'}\``);
63
+ }
64
+ else if (last?.status === 'rolled_back') {
65
+ parts.push('on the previous image: the current one did not come back healthy here');
66
+ }
67
+ else if (last?.status === 'failed') {
68
+ parts.push('the last image update did not complete on either image; files are kept');
69
+ }
70
+ else {
71
+ parts.push('current');
72
+ }
73
+ if (last?.status === 'succeeded' && last.settledAt)
74
+ parts.push(`updated ${last.settledAt}`);
75
+ if (last?.status === 'rolled_back' && last.settledAt) {
76
+ parts.push(`rolled back ${last.settledAt}`);
77
+ }
78
+ if (last?.status === 'failed' && last.settledAt)
79
+ parts.push(`update failed ${last.settledAt}`);
80
+ return parts.join(' · ');
81
+ }
45
82
  /**
46
83
  * Flags in, request body out — and the point of it being a function is the
47
84
  * OMISSION.
@@ -61,7 +98,9 @@ function dedicatedRuntimeCreateInput(input) {
61
98
  requestId: input.requestId,
62
99
  displayName: input.displayName,
63
100
  sizePreset: input.sizePreset,
64
- monthlySpendingLimitCents: input.monthlySpendingLimitCents,
101
+ ...(input.monthlySpendingLimitCents !== undefined
102
+ ? { monthlySpendingLimitCents: input.monthlySpendingLimitCents }
103
+ : {}),
65
104
  ...(input.regionClass ? { regionClass: input.regionClass } : {}),
66
105
  ...(input.storageGb !== undefined ? { storageGb: input.storageGb } : {}),
67
106
  ...(input.retentionClass ? { retentionClass: input.retentionClass } : {}),
@@ -226,8 +265,8 @@ async function observeDedicatedRuntimeExecution(leaseId, executionId, options) {
226
265
  /** Mirrors the daemon's bounds (daemon/src/tools/file-transfer.ts). */
227
266
  exports.DEDICATED_FILE_CHUNK_BYTES = 512 * 1024;
228
267
  exports.DEDICATED_FILE_MAX_BYTES = 64 * 1024 * 1024;
229
- async function readDedicatedRuntimeFileChunk(leaseId, input, scope = {}) {
230
- return (0, api_fetch_1.apiFetch)(withDedicatedScope(`/api/machines/dedicated/${encodeURIComponent(leaseId)}/files/read`, scope), { method: 'POST', body: input });
268
+ async function readDedicatedRuntimeFileChunk(leaseId, input, scope = {}, signal) {
269
+ return (0, api_fetch_1.apiFetch)(withDedicatedScope(`/api/machines/dedicated/${encodeURIComponent(leaseId)}/files/read`, scope), { method: 'POST', body: input, ...(signal ? { signal } : {}) });
231
270
  }
232
271
  async function writeDedicatedRuntimeFileChunk(leaseId, input, scope = {}) {
233
272
  return (0, api_fetch_1.apiFetch)(withDedicatedScope(`/api/machines/dedicated/${encodeURIComponent(leaseId)}/files/write`, scope), { method: 'POST', body: input });
@@ -308,6 +347,14 @@ function describeDedicatedDeletion(deletion, leaseId) {
308
347
  if (deletion.status === 'purging') {
309
348
  return 'recovery window closed; every copy is being deleted';
310
349
  }
350
+ if (deletion.status === 'withheld') {
351
+ return deletion.restorable
352
+ ? `deletion stopped after the machine was shut down; files kept — \`skrr machines dedicated restore ${leaseId || '<lease-id>'}\``
353
+ : 'deletion stopped after the machine was shut down; files kept';
354
+ }
355
+ if (deletion.restorable && deletion.supersededByLeaseId) {
356
+ return `previous copy kept until ${deletion.recoverableUntil} (restored into ${deletion.supersededByLeaseId}); restorable until then — \`skrr machines dedicated restore ${leaseId || '<lease-id>'}\``;
357
+ }
311
358
  if (deletion.restorable) {
312
359
  return `restorable until ${deletion.recoverableUntil} — \`skrr machines dedicated restore ${leaseId || '<lease-id>'}\``;
313
360
  }
@@ -348,6 +395,9 @@ function describeDedicatedLease(lease, harnesses = []) {
348
395
  row('last backup', storage.lastBackupAt);
349
396
  const image = [resources.imageVersion || lease.daemon?.imageVersion].filter(Boolean).join('');
350
397
  row('image', image);
398
+ if (lease.health?.image) {
399
+ row('image update', describeDedicatedImageStanding(lease.health.image, dedicatedLeaseId(lease)));
400
+ }
351
401
  if (lease.daemon?.version || lease.daemon?.lastHeartbeatAt) {
352
402
  row('daemon', [
353
403
  lease.daemon.version ? `skrrd ${lease.daemon.version}` : '',
@@ -358,19 +408,18 @@ function describeDedicatedLease(lease, harnesses = []) {
358
408
  }
359
409
  const health = lease.health;
360
410
  if (health && typeof health.assignable === 'boolean') {
361
- row('health', health.assignable
362
- ? 'can take work'
363
- : `not assignable${health.reasons?.length ? `: ${[...new Set(health.reasons)].join(', ')}` : ''}`);
411
+ const why = health.assignable
412
+ ? ''
413
+ : (0, harnesses_1.describeHealthReasonSentences)(health.reasons, dedicatedLeaseState(lease));
414
+ row('health', health.assignable ? 'can take work' : `not assignable${why ? `: ${why}` : ''}`);
364
415
  }
365
416
  row('spend', lease.billing ? dedicatedSpendSummary(lease) : undefined);
366
- if (storage.deleteAfter || storage.unpaidSince) {
367
- row('deletion', [
368
- storage.unpaidSince ? `unpaid since ${storage.unpaidSince}` : '',
369
- storage.deletionNoticeAt ? `notice ${storage.deletionNoticeAt}` : '',
370
- storage.deleteAfter ? `workspace deleted after ${storage.deleteAfter}` : '',
371
- ]
372
- .filter(Boolean)
373
- .join(' · '));
417
+ const deletionPlan = dedicatedDeletionLines(lease);
418
+ if (deletionPlan.length > 0) {
419
+ row('deletion', deletionPlan[0]);
420
+ for (const line of deletionPlan.slice(1)) {
421
+ lines.push(` ${''.padEnd(13)}${line}`);
422
+ }
374
423
  }
375
424
  const deletion = storage.deletion;
376
425
  if (deletion?.status) {
@@ -394,6 +443,178 @@ function describeDedicatedLease(lease, harnesses = []) {
394
443
  row('support ref', lease.supportReference);
395
444
  return lines;
396
445
  }
446
+ /** "2026-09-30 14:00 UTC (in 17 days)" — a deadline readable without converting it. */
447
+ function formatDedicatedDeadline(value, now = new Date()) {
448
+ const at = value ? new Date(value) : null;
449
+ if (!at || Number.isNaN(at.getTime()))
450
+ return 'unknown';
451
+ const stamp = `${at.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
452
+ const days = Math.round((at.getTime() - now.getTime()) / 86_400_000);
453
+ const relative = days === 0
454
+ ? 'today'
455
+ : days > 0
456
+ ? `in ${days} day${days === 1 ? '' : 's'}`
457
+ : `${-days} day${days === -1 ? '' : 's'} ago`;
458
+ return `${stamp} (${relative})`;
459
+ }
460
+ /** Why, by the remedy when the plan names one; by the reason for an older plan. */
461
+ const DELETION_REASON_BY_REMEDY = {
462
+ pay_invoice: 'an invoice for its usage is unpaid',
463
+ subscribe: 'the plan it needs has ended',
464
+ };
465
+ const DELETION_REASON_TEXT = {
466
+ plan_inactive: DELETION_REASON_BY_REMEDY.subscribe,
467
+ subscription_canceled: 'subscription cancelled',
468
+ payment_failed: 'payment failed and retries finished',
469
+ unknown: 'billing account not in good standing',
470
+ };
471
+ const LEGACY_DELETION_REMEDY_TEXT = {
472
+ subscription_canceled: 'unless you subscribe again',
473
+ payment_failed: 'unless the outstanding invoice is paid',
474
+ unknown: 'unless billing is restored',
475
+ };
476
+ /** An invoice's amount in its own currency, from Stripe's minor units. */
477
+ function formatInvoiceAmount(amountDue, currency) {
478
+ const code = String(currency || '').toUpperCase();
479
+ if (!/^[A-Z]{3}$/.test(code) || !Number.isSafeInteger(amountDue))
480
+ return null;
481
+ try {
482
+ const format = new Intl.NumberFormat('en-US', { style: 'currency', currency: code });
483
+ return format.format(amountDue / 10 ** (format.resolvedOptions().maximumFractionDigits ?? 2));
484
+ }
485
+ catch {
486
+ return null;
487
+ }
488
+ }
489
+ /**
490
+ * Exactly the remedy that clears the cause, as the tail of "workspace deleted
491
+ * after …", and the pages it is carried out at: each unpaid invoice's (for an
492
+ * owner who pays it), or Billing in the app to subscribe again.
493
+ */
494
+ function dedicatedDeletionRemedy(plan, lease) {
495
+ const remedy = plan.remedy;
496
+ if (remedy?.kind === 'pay_invoice') {
497
+ if (!remedy.paidByOwner || remedy.invoices.length === 0) {
498
+ return {
499
+ clause: "unless the unpaid invoice is paid (it was sent to your workspace's billing contact)",
500
+ lines: [],
501
+ };
502
+ }
503
+ return {
504
+ clause: `unless the unpaid invoice${remedy.invoices.length === 1 ? ' is' : 's are'} paid`,
505
+ lines: [
506
+ ...remedy.invoices.map((invoice) => {
507
+ const amount = formatInvoiceAmount(invoice.amountDue, invoice.currency);
508
+ return `pay: ${amount ? `${amount} ` : ''}${invoice.hostedInvoiceUrl}`;
509
+ }),
510
+ // Read again from Stripe with the plan, like the machine's page; a link in
511
+ // an email can expire.
512
+ "a payment link in an email can expire; this one, and the machine's page on the web, stay current",
513
+ ],
514
+ };
515
+ }
516
+ if (remedy?.kind === 'subscribe') {
517
+ return {
518
+ clause: lease.workspaceId
519
+ ? "unless the workspace subscribes again (the workspace's Settings → Billing in the app)"
520
+ : 'unless you subscribe again (Billing in the app)',
521
+ lines: [],
522
+ };
523
+ }
524
+ return {
525
+ clause: LEGACY_DELETION_REMEDY_TEXT[plan.reason || 'unknown'] || LEGACY_DELETION_REMEDY_TEXT.unknown,
526
+ lines: [],
527
+ };
528
+ }
529
+ const DELETION_BLOCKER_TEXT = {
530
+ billing_status_unavailable: 'billing status could not be read',
531
+ paused: 'deletions are paused by an operator',
532
+ hold: 'a hold is open on this workspace',
533
+ other_operation_running: 'another operation on this machine is running',
534
+ invoice_added: 'another invoice became unpaid; a notice naming it is being sent',
535
+ not_stopped: 'the machine is not stopped',
536
+ notice_not_delivered: 'no deletion notice has reached your email yet',
537
+ final_notice_not_delivered: 'the final notice has not reached your email yet',
538
+ final_notice_resent: 'the final notice is being sent again',
539
+ notice_undeliverable: 'the deletion notice could not be delivered to your email',
540
+ email_unverified: 'your email address is not verified',
541
+ email_changed: 'your email address changed; the notice is being sent to the new one',
542
+ cause_changed: 'the billing state changed; a new notice is being sent',
543
+ billing_contact_unavailable: "the billing account's contact could not be read",
544
+ changed_concurrently: 'the lease changed while deleting',
545
+ };
546
+ const NOTICE_TEXT = {
547
+ compute_stopped: 'stopped',
548
+ deletion_notice: 'deletion notice',
549
+ early_reminder: '30-day reminder',
550
+ reminder: '7-day reminder',
551
+ final_notice: '1-day notice',
552
+ deletion_cancelled: 'cancellation',
553
+ archive_reminder: '7-day reminder',
554
+ archive_final_reminder: '1-day reminder',
555
+ };
556
+ /**
557
+ * The deletion plan as lines for `show`: what will happen and when first, then
558
+ * why, which notices reached the owner, and what is holding it back.
559
+ */
560
+ function dedicatedDeletionLines(lease, now = new Date()) {
561
+ // ONE source: the server's plan. `storage.deleteAfter` and `unpaidSince` are
562
+ // its compatibility fields, and a stopped lease's stored date is a deadline only
563
+ // once a delivered notice started it — so they are not read here.
564
+ const plan = lease.storage?.deletionPlan;
565
+ if (!plan)
566
+ return [];
567
+ const lines = [];
568
+ const remedy = dedicatedDeletionRemedy(plan, lease);
569
+ let remedyApplies = false;
570
+ if (plan.kind === 'retention_period') {
571
+ lines.push(`workspace deleted after ${formatDedicatedDeadline(plan.deleteAfter, now)}`);
572
+ }
573
+ else if (plan.status === 'cancelled') {
574
+ const kept = plan.workspaceWithheld
575
+ ? ' — the machine had been shut down; files kept, restore them into a new machine'
576
+ : ' — workspace kept';
577
+ lines.push(`deletion cancelled ${formatDedicatedDeadline(plan.cancelledAt, now)}${plan.cancelReason === 'billing_resolved' ? ` — billing in good standing${kept}` : plan.workspaceWithheld ? kept : ''}`);
578
+ }
579
+ else if (plan.status === 'deleting') {
580
+ // Until its files are about to be deleted, clearing the cause still stops it.
581
+ remedyApplies = plan.cancellable === true;
582
+ lines.push(remedyApplies
583
+ ? `workspace is being deleted — not too late: it stops, with every file kept, ${remedy.clause.replace(/^unless /, 'if ')}`
584
+ : 'workspace is being deleted');
585
+ }
586
+ else if (plan.status === 'scheduled') {
587
+ remedyApplies = true;
588
+ lines.push(`workspace deleted after ${formatDedicatedDeadline(plan.deleteAfter, now)} ${remedy.clause}`);
589
+ }
590
+ else {
591
+ remedyApplies = true;
592
+ lines.push(`files kept until at least ${formatDedicatedDeadline(plan.retentionEndsAt, now)}; no deletion date until a deletion notice reaches your email, then at least ${plan.noticeFloorDays || 30} days; no deletion at all ${remedy.clause.replace(/^unless /, 'if ')}`);
593
+ }
594
+ if (remedyApplies)
595
+ lines.push(...remedy.lines);
596
+ if (plan.kind === 'unpaid' && plan.status !== 'cancelled') {
597
+ const unpaid = plan.remedy?.kind === 'pay_invoice';
598
+ const why = [
599
+ plan.entitlementEndedAt
600
+ ? `${unpaid ? 'unpaid since' : 'billing ended'} ${formatDedicatedDeadline(plan.entitlementEndedAt, now)}`
601
+ : '',
602
+ (plan.remedy && DELETION_REASON_BY_REMEDY[plan.remedy.kind]) ||
603
+ (plan.reason ? DELETION_REASON_TEXT[plan.reason] || plan.reason : ''),
604
+ ].filter(Boolean);
605
+ if (why.length > 0)
606
+ lines.push(why.join(' · '));
607
+ }
608
+ const delivered = (plan.notices || [])
609
+ .filter((notice) => notice.deliveredAt)
610
+ .map((notice) => `${NOTICE_TEXT[notice.notice] || notice.notice} ${formatDedicatedDeadline(notice.deliveredAt, now)} (${(notice.channels || []).join(', ')})`);
611
+ if (delivered.length > 0)
612
+ lines.push(`notices: ${delivered.join(' · ')}`);
613
+ if (plan.blockedBy) {
614
+ lines.push(`held back: ${DELETION_BLOCKER_TEXT[plan.blockedBy] || plan.blockedBy}`);
615
+ }
616
+ return lines;
617
+ }
397
618
  /**
398
619
  * Every harness row a Dedicated Runtime lease's daemon registered, freshest
399
620
  * first.
@@ -609,6 +830,43 @@ function dedicatedRuntimeApiErrorCode(err) {
609
830
  }
610
831
  return err.status ? `HTTP_${err.status}` : undefined;
611
832
  }
833
+ /**
834
+ * The public `details` object a refusal carried — `state`, `action` and the
835
+ * health `reasons` of a failed health check — for the `--json` envelope.
836
+ *
837
+ * The server builds it from an allow-list of safe values (reason codes, lease
838
+ * states), so it is passed through whole rather than re-filtered here, where a
839
+ * second list would only drift from the first.
840
+ */
841
+ function dedicatedRuntimeApiErrorDetails(err) {
842
+ if (!(err instanceof api_fetch_1.ApiFetchError) || !err.body) {
843
+ return undefined;
844
+ }
845
+ try {
846
+ const parsed = JSON.parse(err.body);
847
+ const details = parsed?.details;
848
+ return details && typeof details === 'object' && !Array.isArray(details)
849
+ ? details
850
+ : undefined;
851
+ }
852
+ catch {
853
+ return undefined;
854
+ }
855
+ }
856
+ /**
857
+ * The health reasons a refusal named, deduplicated, as the sentence's tail.
858
+ *
859
+ * A failed `health-check` exists to say WHY a machine cannot take work, and the
860
+ * sentence alone never did: it printed "did not pass health checks." and kept
861
+ * `image_version_missing` to itself (OSK-8687). It then printed the codes, which
862
+ * say nothing to the person running it (OSK-8768); `--json` keeps them in
863
+ * `error.details.reasons`.
864
+ */
865
+ function describeRefusalReasons(err) {
866
+ const details = dedicatedRuntimeApiErrorDetails(err);
867
+ const sentences = (0, harnesses_1.describeHealthReasonSentences)(details?.reasons, details?.state);
868
+ return sentences ? ` Reasons: ${sentences}` : '';
869
+ }
612
870
  function formatDedicatedRuntimeApiError(err) {
613
871
  if (!(err instanceof api_fetch_1.ApiFetchError) || !err.body) {
614
872
  return null;
@@ -632,7 +890,8 @@ function formatDedicatedRuntimeApiError(err) {
632
890
  }
633
891
  try {
634
892
  const parsed = JSON.parse(err.body);
635
- return parsed.message || parsed.error || parsed.code || null;
893
+ const sentence = parsed.message || parsed.error || parsed.code;
894
+ return sentence ? `${sentence}${describeRefusalReasons(err)}` : null;
636
895
  }
637
896
  catch {
638
897
  return err.body.trim() || null;
@@ -1,4 +1,5 @@
1
1
  import { io, type Socket } from 'socket.io-client';
2
+ import { type PendingShellCommandStatus } from '@skrr-ai/data-provider';
2
3
  /**
3
4
  * A real pseudo-terminal on a Dedicated Runtime, from a local terminal.
4
5
  *
@@ -17,13 +18,6 @@ import { io, type Socket } from 'socket.io-client';
17
18
  export declare const TERMINAL_DETACH_BYTE = 29;
18
19
  /** How long the machine has to answer a start before the CLI gives up. */
19
20
  export declare const TERMINAL_START_TIMEOUT_MS = 30000;
20
- /**
21
- * When to type an initial command: once the shell has printed something and
22
- * then gone quiet, or after a fallback — the same heuristic the web terminal
23
- * uses for its Sign in menu, because both are waiting on the same login bash.
24
- */
25
- export declare const TERMINAL_READY_QUIET_MS = 300;
26
- export declare const TERMINAL_READY_FALLBACK_MS = 2500;
27
21
  export interface TerminalOutputFrame {
28
22
  sessionId?: string;
29
23
  daemonId?: string;
@@ -44,25 +38,11 @@ export interface TerminalStartAck {
44
38
  /** Bytes of one output frame, without splitting a UTF-8 or ANSI sequence. */
45
39
  export declare function decodeTerminalFrame(frame: TerminalOutputFrame): Buffer;
46
40
  /**
47
- * Types `command` once, when the shell looks ready.
48
- *
49
- * Returned as an object with injectable timers so the readiness rule is tested
50
- * without a PTY. `onOutput` re-arms the quiet timer; the fallback fires
51
- * regardless, because a shell with a silent prompt must not wait forever.
41
+ * What the terminal tells the user about an initial command that has not been
42
+ * typed. The web terminal shows the same two states with a button; here the
43
+ * user types it themselves.
52
44
  */
53
- export declare function createInitialCommandTyper(options: {
54
- command: string;
55
- send: (data: string) => void;
56
- quietMs?: number;
57
- fallbackMs?: number;
58
- setTimer?: (fn: () => void, ms: number) => unknown;
59
- clearTimer?: (handle: unknown) => void;
60
- }): {
61
- start(): void;
62
- onOutput(): void;
63
- cancel(): void;
64
- readonly typed: boolean;
65
- };
45
+ export declare function describePendingCommand(status: PendingShellCommandStatus, command: string): string | null;
66
46
  export declare class DedicatedTerminalError extends Error {
67
47
  readonly code: string;
68
48
  constructor(message: string, code: string);
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DedicatedTerminalError = exports.TERMINAL_READY_FALLBACK_MS = exports.TERMINAL_READY_QUIET_MS = exports.TERMINAL_START_TIMEOUT_MS = exports.TERMINAL_DETACH_BYTE = void 0;
3
+ exports.DedicatedTerminalError = exports.TERMINAL_START_TIMEOUT_MS = exports.TERMINAL_DETACH_BYTE = void 0;
4
4
  exports.decodeTerminalFrame = decodeTerminalFrame;
5
- exports.createInitialCommandTyper = createInitialCommandTyper;
5
+ exports.describePendingCommand = describePendingCommand;
6
6
  exports.describeTerminalStartRefusal = describeTerminalStartRefusal;
7
7
  exports.runDedicatedTerminal = runDedicatedTerminal;
8
8
  const node_crypto_1 = require("node:crypto");
@@ -27,13 +27,6 @@ const agentic_stream_1 = require("./agentic-stream");
27
27
  exports.TERMINAL_DETACH_BYTE = 0x1d;
28
28
  /** How long the machine has to answer a start before the CLI gives up. */
29
29
  exports.TERMINAL_START_TIMEOUT_MS = 30_000;
30
- /**
31
- * When to type an initial command: once the shell has printed something and
32
- * then gone quiet, or after a fallback — the same heuristic the web terminal
33
- * uses for its Sign in menu, because both are waiting on the same login bash.
34
- */
35
- exports.TERMINAL_READY_QUIET_MS = 300;
36
- exports.TERMINAL_READY_FALLBACK_MS = 2_500;
37
30
  /** Bytes of one output frame, without splitting a UTF-8 or ANSI sequence. */
38
31
  function decodeTerminalFrame(frame) {
39
32
  const encoded = frame.dataBase64 ?? (frame.encoding === 'base64' ? frame.data : undefined);
@@ -43,54 +36,17 @@ function decodeTerminalFrame(frame) {
43
36
  return Buffer.from(typeof frame.data === 'string' ? frame.data : '', 'utf8');
44
37
  }
45
38
  /**
46
- * Types `command` once, when the shell looks ready.
47
- *
48
- * Returned as an object with injectable timers so the readiness rule is tested
49
- * without a PTY. `onOutput` re-arms the quiet timer; the fallback fires
50
- * regardless, because a shell with a silent prompt must not wait forever.
39
+ * What the terminal tells the user about an initial command that has not been
40
+ * typed. The web terminal shows the same two states with a button; here the
41
+ * user types it themselves.
51
42
  */
52
- function createInitialCommandTyper(options) {
53
- const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
54
- const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
55
- const quietMs = options.quietMs ?? exports.TERMINAL_READY_QUIET_MS;
56
- const fallbackMs = options.fallbackMs ?? exports.TERMINAL_READY_FALLBACK_MS;
57
- let typed = false;
58
- let quietTimer = null;
59
- let fallbackTimer = null;
60
- const type = () => {
61
- if (typed)
62
- return;
63
- typed = true;
64
- if (quietTimer)
65
- clearTimer(quietTimer);
66
- if (fallbackTimer)
67
- clearTimer(fallbackTimer);
68
- options.send(`${options.command}\r`);
69
- };
70
- return {
71
- start() {
72
- if (typed || fallbackTimer)
73
- return;
74
- fallbackTimer = setTimer(type, fallbackMs);
75
- },
76
- onOutput() {
77
- if (typed)
78
- return;
79
- if (quietTimer)
80
- clearTimer(quietTimer);
81
- quietTimer = setTimer(type, quietMs);
82
- },
83
- cancel() {
84
- typed = true;
85
- if (quietTimer)
86
- clearTimer(quietTimer);
87
- if (fallbackTimer)
88
- clearTimer(fallbackTimer);
89
- },
90
- get typed() {
91
- return typed;
92
- },
93
- };
43
+ function describePendingCommand(status, command) {
44
+ if (status === 'overdue') {
45
+ return `[waiting for the shell prompt to type: ${command}; type anything and it is left to you]`;
46
+ }
47
+ if (status === 'yielded')
48
+ return `[not typing ${command}: you typed first]`;
49
+ return null;
94
50
  }
95
51
  class DedicatedTerminalError extends Error {
96
52
  code;
@@ -143,10 +99,19 @@ function runDedicatedTerminal(options) {
143
99
  // different process tree mid-command. Say it ended instead.
144
100
  reconnection: false,
145
101
  });
146
- const typer = options.initialCommand
147
- ? createInitialCommandTyper({
148
- command: options.initialCommand,
102
+ // Typed only once the shell's line editor says it is reading a command line
103
+ // (`createPendingShellCommand`, the rule the web terminal reads too). A
104
+ // login profile that sleeps or asks a question is never typed into.
105
+ const { initialCommand } = options;
106
+ const pending = initialCommand
107
+ ? (0, data_provider_1.createPendingShellCommand)({
108
+ command: initialCommand,
149
109
  send: (data) => socket.emit(events.input, { sessionId, data }),
110
+ onStatusChange: (status) => {
111
+ const notice = describePendingCommand(status, initialCommand);
112
+ if (notice && !finished)
113
+ errorOutput.write(`\r\n${notice}\r\n`);
114
+ },
150
115
  })
151
116
  : null;
152
117
  const size = () => ({
@@ -162,26 +127,32 @@ function runDedicatedTerminal(options) {
162
127
  const detachAt = buffer.indexOf(exports.TERMINAL_DETACH_BYTE);
163
128
  if (detachAt >= 0) {
164
129
  if (detachAt > 0) {
165
- socket.emit(events.input, {
166
- sessionId,
167
- data: buffer.subarray(0, detachAt).toString('utf8'),
168
- });
130
+ const typed = buffer.subarray(0, detachAt);
131
+ pending?.observeUserInput(typed);
132
+ socket.emit(events.input, { sessionId, data: typed.toString('utf8') });
169
133
  }
170
134
  errorOutput.write('\r\n[closed the terminal]\r\n');
171
135
  finish(0, true);
172
136
  return;
173
137
  }
138
+ pending?.observeUserInput(buffer);
174
139
  socket.emit(events.input, { sessionId, data: buffer.toString('utf8') });
175
140
  };
176
- const onSignal = () => finish(130, true);
141
+ // A terminal that closes (SIGHUP) or a supervisor that stops us (SIGTERM):
142
+ // put the TTY back, stop the remote shell, and exit 128 + the signal's
143
+ // number. Unreachable until OSK-8708 — auth-core's lock handler exited
144
+ // first and left the local TTY in raw mode — and it said 130, which is
145
+ // SIGINT's status, for both.
146
+ const onSigterm = () => finish(143, true);
147
+ const onSighup = () => finish(129, true);
177
148
  const startTimer = setTimeout(() => {
178
149
  fail(new DedicatedTerminalError('The machine did not answer the terminal start in time. Its daemon may be busy or offline.', 'TERMINAL_START_TIMEOUT'));
179
150
  }, exports.TERMINAL_START_TIMEOUT_MS);
180
151
  function restoreTty() {
181
152
  input.off('data', onInput);
182
153
  output.off('resize', onResize);
183
- process.off('SIGTERM', onSignal);
184
- process.off('SIGHUP', onSignal);
154
+ process.off('SIGTERM', onSigterm);
155
+ process.off('SIGHUP', onSighup);
185
156
  if (rawMode && typeof input.setRawMode === 'function') {
186
157
  input.setRawMode(false);
187
158
  rawMode = false;
@@ -193,7 +164,7 @@ function runDedicatedTerminal(options) {
193
164
  return;
194
165
  finished = true;
195
166
  clearTimeout(startTimer);
196
- typer?.cancel();
167
+ pending?.close();
197
168
  restoreTty();
198
169
  if (stopRemote && started)
199
170
  socket.emit(events.stop, { sessionId });
@@ -205,7 +176,7 @@ function runDedicatedTerminal(options) {
205
176
  return;
206
177
  finished = true;
207
178
  clearTimeout(startTimer);
208
- typer?.cancel();
179
+ pending?.close();
209
180
  restoreTty();
210
181
  socket.disconnect();
211
182
  reject(error);
@@ -239,9 +210,9 @@ function runDedicatedTerminal(options) {
239
210
  input.resume();
240
211
  input.on('data', onInput);
241
212
  output.on('resize', onResize);
242
- process.on('SIGTERM', onSignal);
243
- process.on('SIGHUP', onSignal);
244
- typer?.start();
213
+ process.on('SIGTERM', onSigterm);
214
+ process.on('SIGHUP', onSighup);
215
+ pending?.beginSession();
245
216
  });
246
217
  socket.on(events.output, (frame) => {
247
218
  if (!frame || frame.sessionId !== sessionId || finished)
@@ -258,8 +229,9 @@ function runDedicatedTerminal(options) {
258
229
  errorOutput.write(`\r\n[${frame.type}] ${text}\r\n`);
259
230
  return;
260
231
  }
261
- output.write(decodeTerminalFrame(frame));
262
- typer?.onOutput();
232
+ const bytes = decodeTerminalFrame(frame);
233
+ output.write(bytes);
234
+ pending?.observeOutput(bytes);
263
235
  });
264
236
  socket.on('disconnect', () => {
265
237
  if (finished)
@@ -20,6 +20,9 @@ export type DedicatedWaitGoal = {
20
20
  } | {
21
21
  kind: 'grown';
22
22
  storageGb: number;
23
+ } | {
24
+ kind: 'image_updated';
25
+ previousUpdateStartedAt: string | null;
23
26
  };
24
27
  export type DedicatedWaitVerdict = {
25
28
  status: 'done';
@@ -37,6 +40,13 @@ export type DedicatedWaitVerdict = {
37
40
  * reading that cannot turn into the goal without someone acting again; anything
38
41
  * that might still settle is `pending`, and the caller's timeout decides how long
39
42
  * that is worth watching.
43
+ *
44
+ * Reaching the goal is not the end of the wait while the lease's operation is
45
+ * still in progress: `--wait` promises the operation has FINISHED, and the
46
+ * server refuses the next one until it has. A create read `ready` the moment its
47
+ * guest registered and returned, and the restart that followed was refused for
48
+ * minutes (OSK-8775). A server that does not report the operation is judged on
49
+ * the goal alone, as before.
40
50
  */
41
51
  export declare function evaluateDedicatedWait(goal: DedicatedWaitGoal, lease: DedicatedLease | undefined | null): DedicatedWaitVerdict;
42
52
  /**