agent-nuvira 3.5.6 → 3.5.7

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 (110) hide show
  1. package/.agents/skills/index.json +16 -0
  2. package/.agents/skills/motion/SKILL.md +27 -0
  3. package/README.md +56 -1
  4. package/dist/cli/chat.d.ts +76 -17
  5. package/dist/cli/chat.d.ts.map +1 -1
  6. package/dist/cli/chat.js +258 -118
  7. package/dist/cli/chat.js.map +1 -1
  8. package/dist/cli/doctor.d.ts +31 -2
  9. package/dist/cli/doctor.d.ts.map +1 -1
  10. package/dist/cli/doctor.js +143 -7
  11. package/dist/cli/doctor.js.map +1 -1
  12. package/dist/cli/loop-executor.d.ts.map +1 -1
  13. package/dist/cli/loop-executor.js +4 -1
  14. package/dist/cli/loop-executor.js.map +1 -1
  15. package/dist/cli/parity.d.ts +12 -0
  16. package/dist/cli/parity.d.ts.map +1 -1
  17. package/dist/cli/parity.js +46 -1
  18. package/dist/cli/parity.js.map +1 -1
  19. package/dist/cli/tool-install-prompt.d.ts +2 -7
  20. package/dist/cli/tool-install-prompt.d.ts.map +1 -1
  21. package/dist/cli/tool-install-prompt.js +11 -28
  22. package/dist/cli/tool-install-prompt.js.map +1 -1
  23. package/dist/config/scratch-workspace.d.ts +134 -0
  24. package/dist/config/scratch-workspace.d.ts.map +1 -0
  25. package/dist/config/scratch-workspace.js +213 -0
  26. package/dist/config/scratch-workspace.js.map +1 -0
  27. package/dist/gateway/adapters.d.ts +24 -0
  28. package/dist/gateway/adapters.d.ts.map +1 -1
  29. package/dist/gateway/adapters.js +12 -0
  30. package/dist/gateway/adapters.js.map +1 -1
  31. package/dist/gateway/oversized-reply.d.ts +39 -0
  32. package/dist/gateway/oversized-reply.d.ts.map +1 -0
  33. package/dist/gateway/oversized-reply.js +54 -0
  34. package/dist/gateway/oversized-reply.js.map +1 -0
  35. package/dist/gateway/registry.d.ts +0 -10
  36. package/dist/gateway/registry.d.ts.map +1 -1
  37. package/dist/gateway/registry.js +148 -4
  38. package/dist/gateway/registry.js.map +1 -1
  39. package/dist/learning/quota-ledger.d.ts +24 -0
  40. package/dist/learning/quota-ledger.d.ts.map +1 -1
  41. package/dist/learning/quota-ledger.js +40 -0
  42. package/dist/learning/quota-ledger.js.map +1 -1
  43. package/dist/learning/runtime-dependencies.d.ts +183 -0
  44. package/dist/learning/runtime-dependencies.d.ts.map +1 -0
  45. package/dist/learning/runtime-dependencies.js +349 -0
  46. package/dist/learning/runtime-dependencies.js.map +1 -0
  47. package/dist/learning/skill-store.d.ts.map +1 -1
  48. package/dist/learning/skill-store.js +6 -5
  49. package/dist/learning/skill-store.js.map +1 -1
  50. package/dist/skills/bundled-skills-phase3.d.ts +14 -0
  51. package/dist/skills/bundled-skills-phase3.d.ts.map +1 -1
  52. package/dist/skills/bundled-skills-phase3.js +50 -0
  53. package/dist/skills/bundled-skills-phase3.js.map +1 -1
  54. package/dist/tools/modality/motion-presets.d.ts +62 -0
  55. package/dist/tools/modality/motion-presets.d.ts.map +1 -0
  56. package/dist/tools/modality/motion-presets.js +399 -0
  57. package/dist/tools/modality/motion-presets.js.map +1 -0
  58. package/dist/tools/modality/motion-tool.d.ts +62 -0
  59. package/dist/tools/modality/motion-tool.d.ts.map +1 -0
  60. package/dist/tools/modality/motion-tool.js +220 -0
  61. package/dist/tools/modality/motion-tool.js.map +1 -0
  62. package/dist/tools/modality/motion.d.ts +298 -0
  63. package/dist/tools/modality/motion.d.ts.map +1 -0
  64. package/dist/tools/modality/motion.js +514 -0
  65. package/dist/tools/modality/motion.js.map +1 -0
  66. package/dist/tools/registry.d.ts.map +1 -1
  67. package/dist/tools/registry.js +75 -0
  68. package/dist/tools/registry.js.map +1 -1
  69. package/dist/tools/tool-loop.d.ts.map +1 -1
  70. package/dist/tools/tool-loop.js +66 -16
  71. package/dist/tools/tool-loop.js.map +1 -1
  72. package/dist/tools/toolsets.js +2 -2
  73. package/dist/tools/toolsets.js.map +1 -1
  74. package/dist/utils/workspace-need.d.ts +84 -0
  75. package/dist/utils/workspace-need.d.ts.map +1 -0
  76. package/dist/utils/workspace-need.js +227 -0
  77. package/dist/utils/workspace-need.js.map +1 -0
  78. package/dist/web-dashboard/chat-console.d.ts +22 -0
  79. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  80. package/dist/web-dashboard/chat-console.js +65 -11
  81. package/dist/web-dashboard/chat-console.js.map +1 -1
  82. package/dist/web-dashboard/hub-data.d.ts +9 -1
  83. package/dist/web-dashboard/hub-data.d.ts.map +1 -1
  84. package/dist/web-dashboard/hub-data.js +2 -0
  85. package/dist/web-dashboard/hub-data.js.map +1 -1
  86. package/dist/web-dashboard/project-context.d.ts +1 -1
  87. package/dist/web-dashboard/project-context.d.ts.map +1 -1
  88. package/dist/web-dashboard/project-context.js +7 -1
  89. package/dist/web-dashboard/project-context.js.map +1 -1
  90. package/dist/web-dashboard/server.d.ts +33 -0
  91. package/dist/web-dashboard/server.d.ts.map +1 -1
  92. package/dist/web-dashboard/server.js +204 -21
  93. package/dist/web-dashboard/server.js.map +1 -1
  94. package/dist/web-dashboard/src/types.d.ts +46 -0
  95. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  96. package/dist/web-dashboard/workspace-guard.d.ts +20 -21
  97. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  98. package/dist/web-dashboard/workspace-guard.js +20 -153
  99. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  100. package/dist/web-dashboard/workspace-resolution.d.ts +23 -5
  101. package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -1
  102. package/dist/web-dashboard/workspace-resolution.js +22 -4
  103. package/dist/web-dashboard/workspace-resolution.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/web-dashboard/public/assets/{index-DeSsNU94.css → index-CzRU84ET.css} +1 -1
  106. package/src/web-dashboard/public/assets/index-V0rIqVbg.js +236 -0
  107. package/src/web-dashboard/public/assets/index-V0rIqVbg.js.map +1 -0
  108. package/src/web-dashboard/public/index.html +2 -2
  109. package/src/web-dashboard/public/assets/index-Do_O67Kv.js +0 -236
  110. package/src/web-dashboard/public/assets/index-Do_O67Kv.js.map +0 -1
@@ -25,6 +25,7 @@ import { loadEnv } from '../utils/env.js';
25
25
  import { ConfigManager } from '../config/manager.js';
26
26
  import { parseRuntimeSpec } from '../mcp/runtime.js';
27
27
  import { runAllChecks } from '../cli/doctor.js';
28
+ import { dependencySummary, probeRuntimeDependencies, } from '../learning/runtime-dependencies.js';
28
29
  import { getAutoRouter } from '../learning/auto-router.js';
29
30
  import { classifyEntries, } from '../learning/cost-classification.js';
30
31
  import { acceptanceSummary, collectLabelledTurns, serializeLabelledTurns, parseCorpusText, detectCorpusFormat, mergeImportedLabels, } from '../learning/acceptance-model.js';
@@ -55,6 +56,7 @@ import { isCuratedProcessEnvVar, normalizeProcessEnvValue, } from '../config/pro
55
56
  import { FRESH_DAYS, FRESHNESS_COPY, REACHABILITY_COPY, classifyFreshness, classifyReachability, } from '../learning/model-reachability.js';
56
57
  import { VERIFY_BACKLOG_DEFAULT_PER_RUN, VERIFY_BACKLOG_MAX_PER_RUN, getVerifyBacklogState, startVerifyBacklogRun, } from '../learning/model-verify-job.js';
57
58
  import { setToolsetEnabled } from '../tools/toolsets.js';
59
+ import { ArtifactStore } from '../tools/artifact-store.js';
58
60
  import { setSkillEnabled } from '../learning/hub-skill-catalog.js';
59
61
  // I11 — dashboard channel send-test: the same registry/adapters `nuvira gateway
60
62
  // send` uses, so a Channels-tab test message behaves identically to the CLI.
@@ -68,6 +70,7 @@ import { ChannelDirectory, PLATFORM_ENV_VARS, platformConfigVars } from '../gate
68
70
  import { applyEnvToProcess, configurablePlatforms, envVarState, platformConfigStatus, platformEnvVarMeta, redactValue, writeEnvFile, } from '../gateway/platform-config.js';
69
71
  import { SERVICE_CATALOG, getServiceDefinition, } from '../config/service-catalog.js';
70
72
  import { probeService } from '../config/service-probe.js';
73
+ import { pruneScratchWorkspaces } from '../config/scratch-workspace.js';
71
74
  // ─── Constants ──────────────────────────────────────────────────────────────
72
75
  const PORT = parseInt(envBuff('DASHBOARD_PORT') || '3030', 10);
73
76
  const HOST = envBuff('DASHBOARD_HOST') || '127.0.0.1';
@@ -119,6 +122,40 @@ let alwaysWatchQuota = false;
119
122
  * loadApiKeysFromConfig uses). Best-effort — a missing/corrupt config just
120
123
  * keeps the default (false = arm-on-connect only).
121
124
  */
125
+ /**
126
+ * The content type for an artifact file: the recorded mime when there is one,
127
+ * otherwise the extension. Video and image types are the ones the panel renders
128
+ * inline, so a wrong type is a blank frame rather than a failed download.
129
+ */
130
+ function artifactFileMime(file, recorded) {
131
+ if (recorded && /^[a-z]+\/[a-z0-9.+-]+$/i.test(recorded))
132
+ return recorded;
133
+ const ext = (file.split('.').pop() ?? '').toLowerCase();
134
+ const byExt = {
135
+ mp4: 'video/mp4',
136
+ webm: 'video/webm',
137
+ mov: 'video/quicktime',
138
+ mkv: 'video/x-matroska',
139
+ gif: 'image/gif',
140
+ png: 'image/png',
141
+ jpg: 'image/jpeg',
142
+ jpeg: 'image/jpeg',
143
+ webp: 'image/webp',
144
+ svg: 'image/svg+xml',
145
+ mp3: 'audio/mpeg',
146
+ wav: 'audio/wav',
147
+ m4a: 'audio/mp4',
148
+ srt: 'application/x-subrip',
149
+ vtt: 'text/vtt',
150
+ json: 'application/json',
151
+ md: 'text/markdown',
152
+ txt: 'text/plain',
153
+ log: 'text/plain',
154
+ csv: 'text/csv',
155
+ pdf: 'application/pdf',
156
+ };
157
+ return byExt[ext] ?? 'application/octet-stream';
158
+ }
122
159
  function loadAlwaysWatchQuotaFlag() {
123
160
  try {
124
161
  const configPath = resolveBuffConfigPath();
@@ -2807,14 +2844,42 @@ function buildServiceRow(def, canReadValues) {
2807
2844
  envVars,
2808
2845
  };
2809
2846
  }
2847
+ /**
2848
+ * One runtime tool as the panel's payload. Exported for the server test, which
2849
+ * asserts the panel is fed the same list `nuvira doctor` prints.
2850
+ */
2851
+ export function runtimeToolSummary(status) {
2852
+ return {
2853
+ id: status.dependency.id,
2854
+ label: status.dependency.label,
2855
+ need: status.dependency.need,
2856
+ present: status.present,
2857
+ enables: status.dependency.enables,
2858
+ neededBy: status.dependency.neededBy,
2859
+ // A tool the machine ALREADY has gets no command on the wire, the same way
2860
+ // `nuvira doctor` prints a fix only for what is missing: sending one anyway
2861
+ // invites a user to reinstall something that is working.
2862
+ installCommand: status.present ? null : status.installCommand,
2863
+ note: status.dependency.note,
2864
+ needsHumanInput: status.needsHumanInput,
2865
+ };
2866
+ }
2810
2867
  /** Run ALL state checks — the dashboard command-runner payload. */
2811
2868
  async function runAdminChecks() {
2812
2869
  const configManager = new ConfigManager();
2813
2870
  const { system, enterprise } = await runAllChecks(configManager);
2871
+ // The SAME declaration the CLI's `nuvira doctor`, the `--fix` installer and the
2872
+ // setup scripts read, probed once for this payload. The rows are also in
2873
+ // `system` (one check each) because a pass/warn verdict is what a check list
2874
+ // is for; this is the actionable half — the command to copy, the
2875
+ // recommended/optional split, and the caveat.
2876
+ const statuses = probeRuntimeDependencies();
2814
2877
  return {
2815
2878
  system,
2816
2879
  enterprise,
2817
2880
  providers: buildAdminProviderSummary(configManager),
2881
+ runtimeTools: statuses.map(runtimeToolSummary),
2882
+ runtimeToolsSummary: dependencySummary(statuses),
2818
2883
  serverTime: Date.now(),
2819
2884
  };
2820
2885
  }
@@ -3312,6 +3377,52 @@ function handleRequest(req, res) {
3312
3377
  })();
3313
3378
  return;
3314
3379
  }
3380
+ // ── An artifact's BYTES — how the Artifacts tab shows a rendered video rather
3381
+ // than a filename. The path is looked up in OUR OWN artifact store by (session,
3382
+ // artifact id) and is never taken from the query string, so a caller cannot ask
3383
+ // this endpoint for an arbitrary file; a bad pair is a 404, not a read.
3384
+ if (pathname === '/api/artifact-file') {
3385
+ const sessionId = url.searchParams.get('session') ?? '';
3386
+ const artifactId = url.searchParams.get('artifact') ?? '';
3387
+ const artifact = artifactId
3388
+ ? new ArtifactStore().read(sessionId).find((a) => a.id === artifactId)
3389
+ : undefined;
3390
+ if (!artifact || !artifact.path || !existsSync(artifact.path) || !statSync(artifact.path).isFile()) {
3391
+ writeJson(res, 404, { ok: false, error: 'No such artifact file.' });
3392
+ return;
3393
+ }
3394
+ const size = statSync(artifact.path).size;
3395
+ const type = artifactFileMime(artifact.path, artifact.mime);
3396
+ // Range support, because a <video> that cannot seek cannot be reviewed: a
3397
+ // player asks for byte ranges the moment the user drags the scrubber.
3398
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '');
3399
+ if (range) {
3400
+ const start = range[1] ? Number(range[1]) : 0;
3401
+ const end = range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
3402
+ if (start >= size || end < start) {
3403
+ res.writeHead(416, { 'Content-Range': `bytes */${size}` });
3404
+ res.end();
3405
+ return;
3406
+ }
3407
+ res.writeHead(206, {
3408
+ 'Content-Type': type,
3409
+ 'Content-Length': String(end - start + 1),
3410
+ 'Content-Range': `bytes ${start}-${end}/${size}`,
3411
+ 'Accept-Ranges': 'bytes',
3412
+ 'Cache-Control': 'no-store',
3413
+ });
3414
+ createReadStream(artifact.path, { start, end }).pipe(res);
3415
+ return;
3416
+ }
3417
+ res.writeHead(200, {
3418
+ 'Content-Type': type,
3419
+ 'Content-Length': String(size),
3420
+ 'Accept-Ranges': 'bytes',
3421
+ 'Cache-Control': 'no-store',
3422
+ });
3423
+ createReadStream(artifact.path).pipe(res);
3424
+ return;
3425
+ }
3315
3426
  if (pathname === '/api/benchmarks') {
3316
3427
  res.writeHead(200, { 'Content-Type': 'application/json' });
3317
3428
  res.end(JSON.stringify(readBenchmarkData()));
@@ -3391,7 +3502,17 @@ function handleRequest(req, res) {
3391
3502
  res.end(JSON.stringify(payload));
3392
3503
  }).catch((err) => {
3393
3504
  res.writeHead(500, { 'Content-Type': 'application/json' });
3394
- res.end(JSON.stringify({ error: String(err), system: [], enterprise: [], providers: [], serverTime: Date.now() }));
3505
+ res.end(JSON.stringify({
3506
+ error: String(err),
3507
+ system: [],
3508
+ enterprise: [],
3509
+ providers: [],
3510
+ // An empty list, not a missing one: the panel must be able to say
3511
+ // "nothing reported" on a failed run rather than crash on undefined.
3512
+ runtimeTools: [],
3513
+ runtimeToolsSummary: 'unavailable — the checks run failed',
3514
+ serverTime: Date.now(),
3515
+ }));
3395
3516
  });
3396
3517
  return;
3397
3518
  }
@@ -7598,13 +7719,51 @@ function handleRequest(req, res) {
7598
7719
  */
7599
7720
  const configuredCwd = resolveDashboardCwd();
7600
7721
  const { resolveTurnWorkspace, formatWorkspaceNoticeText } = await import('./workspace-resolution.js');
7722
+ const { workspaceNeedForMissingFolder } = await import('./workspace-guard.js');
7601
7723
  const sessionProjectPath = chatConsole.get(sessionId)?.projectPath;
7602
- const workspace = resolveTurnWorkspace({
7724
+ /**
7725
+ * The same ladder, asked twice when the last rung is needed.
7726
+ *
7727
+ * WHY TWO PASSES. The scratch folder is a real directory on disk, so it is
7728
+ * only created when the ladder has actually RUN OUT (nothing attached,
7729
+ * nothing named in the message, no configured default) AND the ask needs
7730
+ * somewhere to PUT new work. Creating it eagerly would leave an empty
7731
+ * temporary folder behind on every turn that already had a folder, and
7732
+ * handing it to a READ ask would be the kuttaaddon defect again — an empty
7733
+ * folder cannot answer "assess this project".
7734
+ */
7735
+ const resolveWorkspaceNow = (scratchPath) => resolveTurnWorkspace({
7603
7736
  ...(attachedPath ? { attachedPath } : {}),
7604
7737
  ...(sessionProjectPath ? { sessionPath: sessionProjectPath } : {}),
7605
7738
  ...(configuredCwd ? { configuredCwd } : {}),
7606
7739
  message,
7740
+ ...(scratchPath ? { scratchPath } : {}),
7607
7741
  });
7742
+ let workspace = resolveWorkspaceNow();
7743
+ /**
7744
+ * What this ask needs a folder FOR — `'produce'` is the case a temporary
7745
+ * folder can serve, `'read'` is the case it cannot (and keeps the folder
7746
+ * prompt), `null` is plain chat, which needs none.
7747
+ */
7748
+ const missingWorkspaceNeed = workspace.path ? null : workspaceNeedForMissingFolder(message);
7749
+ if (missingWorkspaceNeed === 'produce') {
7750
+ // The scratch folder is offered through the SAME rule the gateway uses
7751
+ // (`scratchWorkspaceForTurn`), so the two surfaces cannot disagree about
7752
+ // when a temporary folder is the right answer — in particular about an
7753
+ // ISOLATED turn, which a scratch folder can never serve: it has no base
7754
+ // commit to diff against. There, this turn has no folder at all, so it
7755
+ // falls through to the folder request below rather than isolating a
7756
+ // directory the user never chose.
7757
+ const { scratchWorkspaceForTurn } = await import('../config/scratch-workspace.js');
7758
+ const { resolveIsolationRequest } = await import('../tools/worktree.js');
7759
+ const scratch = scratchWorkspaceForTurn({
7760
+ key: sessionId,
7761
+ need: missingWorkspaceNeed,
7762
+ isolationAsked: resolveIsolationRequest({ worktree }).asked,
7763
+ });
7764
+ if (scratch)
7765
+ workspace = resolveWorkspaceNow(scratch);
7766
+ }
7608
7767
  if (workspace.path) {
7609
7768
  projectPath = workspace.path;
7610
7769
  const bundle = getProjectBundle(workspace.path);
@@ -7622,24 +7781,23 @@ function handleRequest(req, res) {
7622
7781
  * needs no caption.
7623
7782
  */
7624
7783
  const workspaceNotice = formatWorkspaceNoticeText(workspace);
7625
- // No workspace and no configured default: an ask that names a project, or
7626
- // that produces/consumes files, gets a folder request instead of an answer
7627
- // about the wrong tree. General chat is unaffected.
7628
- if (!workspace.path) {
7629
- const { needsProjectAttachment, projectAttachmentPrompt, projectAttachmentFollowups } = await import('./workspace-guard.js');
7630
- if (needsProjectAttachment(message)) {
7631
- writeJson(res, 200, {
7632
- ok: true,
7633
- sessionId,
7634
- content: projectAttachmentPrompt(),
7635
- followups: projectAttachmentFollowups(),
7636
- provider: null,
7637
- model: null,
7638
- generationFailed: false,
7639
- needsProject: true,
7640
- });
7641
- return;
7642
- }
7784
+ // Still no workspace: either the ask must READ or CHANGE work that already
7785
+ // exists (a temporary folder holds none of it), or the scratch folder could
7786
+ // not be created. Both are the case for a folder request rather than an
7787
+ // answer about the wrong tree. General chat — `null` — is unaffected.
7788
+ if (!workspace.path && missingWorkspaceNeed !== null) {
7789
+ const { projectAttachmentPrompt, projectAttachmentFollowups } = await import('./workspace-guard.js');
7790
+ writeJson(res, 200, {
7791
+ ok: true,
7792
+ sessionId,
7793
+ content: projectAttachmentPrompt(),
7794
+ followups: projectAttachmentFollowups(),
7795
+ provider: null,
7796
+ model: null,
7797
+ generationFailed: false,
7798
+ needsProject: true,
7799
+ });
7800
+ return;
7643
7801
  }
7644
7802
  // P4 (revised) — a client DISCONNECT no longer cancels the turn. It used
7645
7803
  // to: the server treated `res` close as an abort, which meant a page
@@ -7677,6 +7835,14 @@ function handleRequest(req, res) {
7677
7835
  const result = await withStrictModel(strict, () => chatConsole.answer(sessionId, message, {
7678
7836
  provider,
7679
7837
  model,
7838
+ // P8b — the STRICT half of the pin rides into the console so the
7839
+ // CONVERSATION can remember it. `withStrictModel` above scopes it to
7840
+ // this turn, but a conversation whose lock is forgotten between turns
7841
+ // is a pin the user cannot actually rely on: measured live
7842
+ // (trace-1792068695325-42wb1j) the pin survived a reload and the lock
7843
+ // did not, so the engine handed the turn to another model with the
7844
+ // user's "this model only" instruction never in force.
7845
+ strict,
7680
7846
  projectContext,
7681
7847
  projectPath,
7682
7848
  // Cluster G — carry the "no folder attached" state into the turn so a
@@ -7745,7 +7911,12 @@ function handleRequest(req, res) {
7745
7911
  routingNotice = `Auto routing took over: this turn ran on ${served} because your pinned ${requested} was not available. To work with ${requested} only, enable strict model mode.`;
7746
7912
  }
7747
7913
  else if (result.generationFailed === true) {
7748
- routingNotice = `Your pinned ${requested} could not complete this turn, so auto routing may hand the work to another model. To stay on this model only, enable strict model mode.`;
7914
+ // The turn ran on the pair the user pinned and stopped there. Saying
7915
+ // "auto routing may hand the work to another model" described the old
7916
+ // behaviour (a pinned turn followed the health ranking the moment its
7917
+ // pair returned an error) and would now be a lie: a pin waits on itself
7918
+ // and then asks. See `pinnedEscalation`.
7919
+ routingNotice = `Your pinned ${requested} did not answer, and nothing was sent to another model — the turn stopped instead of substituting. Retry it, or switch models.`;
7749
7920
  }
7750
7921
  }
7751
7922
  // Did the offer we are about to send get a mechanism behind it? The client
@@ -8348,6 +8519,18 @@ export function createDashboardServer(opts) {
8348
8519
  handle.ipv6Twin = undefined;
8349
8520
  }
8350
8521
  }
8522
+ // Scratch workspaces are temporary BY DEFINITION, so the ones nobody came back
8523
+ // to are cleaned on boot rather than accumulating in the user's home for the
8524
+ // life of the install (see `pruneScratchWorkspaces`). Best-effort: a failed
8525
+ // prune must never stop the server from coming up.
8526
+ try {
8527
+ const removed = pruneScratchWorkspaces();
8528
+ if (removed > 0)
8529
+ console.log(` 🧹 Removed ${removed} expired scratch workspace(s)`);
8530
+ }
8531
+ catch {
8532
+ /* best-effort */
8533
+ }
8351
8534
  server.listen(bindPort, bindHost, () => {
8352
8535
  console.log(`\n 🌐 Agent-Nuvira Dashboard`);
8353
8536
  console.log(` ─────────────────────────`);