agent-nuvira 3.5.6 → 3.5.9

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 (186) 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/agents/agents/package-agent.d.ts +22 -0
  5. package/dist/agents/agents/package-agent.d.ts.map +1 -1
  6. package/dist/agents/agents/package-agent.js +92 -4
  7. package/dist/agents/agents/package-agent.js.map +1 -1
  8. package/dist/agents/release-preflight.d.ts +42 -5
  9. package/dist/agents/release-preflight.d.ts.map +1 -1
  10. package/dist/agents/release-preflight.js +82 -5
  11. package/dist/agents/release-preflight.js.map +1 -1
  12. package/dist/cli/chat.d.ts +76 -17
  13. package/dist/cli/chat.d.ts.map +1 -1
  14. package/dist/cli/chat.js +357 -127
  15. package/dist/cli/chat.js.map +1 -1
  16. package/dist/cli/code-map.d.ts +51 -0
  17. package/dist/cli/code-map.d.ts.map +1 -1
  18. package/dist/cli/code-map.js +174 -2
  19. package/dist/cli/code-map.js.map +1 -1
  20. package/dist/cli/credentials.d.ts.map +1 -1
  21. package/dist/cli/credentials.js +4 -1
  22. package/dist/cli/credentials.js.map +1 -1
  23. package/dist/cli/doctor.d.ts +31 -2
  24. package/dist/cli/doctor.d.ts.map +1 -1
  25. package/dist/cli/doctor.js +143 -7
  26. package/dist/cli/doctor.js.map +1 -1
  27. package/dist/cli/execute.js +5 -5
  28. package/dist/cli/execute.js.map +1 -1
  29. package/dist/cli/loop-executor.d.ts.map +1 -1
  30. package/dist/cli/loop-executor.js +80 -14
  31. package/dist/cli/loop-executor.js.map +1 -1
  32. package/dist/cli/parity.d.ts +12 -0
  33. package/dist/cli/parity.d.ts.map +1 -1
  34. package/dist/cli/parity.js +46 -1
  35. package/dist/cli/parity.js.map +1 -1
  36. package/dist/cli/tool-install-prompt.d.ts +2 -7
  37. package/dist/cli/tool-install-prompt.d.ts.map +1 -1
  38. package/dist/cli/tool-install-prompt.js +11 -28
  39. package/dist/cli/tool-install-prompt.js.map +1 -1
  40. package/dist/config/process-env.d.ts.map +1 -1
  41. package/dist/config/process-env.js +2 -2
  42. package/dist/config/process-env.js.map +1 -1
  43. package/dist/config/scratch-workspace.d.ts +134 -0
  44. package/dist/config/scratch-workspace.d.ts.map +1 -0
  45. package/dist/config/scratch-workspace.js +213 -0
  46. package/dist/config/scratch-workspace.js.map +1 -0
  47. package/dist/editing/types.d.ts +23 -1
  48. package/dist/editing/types.d.ts.map +1 -1
  49. package/dist/editing/types.js +51 -7
  50. package/dist/editing/types.js.map +1 -1
  51. package/dist/gateway/adapters.d.ts +24 -0
  52. package/dist/gateway/adapters.d.ts.map +1 -1
  53. package/dist/gateway/adapters.js +12 -0
  54. package/dist/gateway/adapters.js.map +1 -1
  55. package/dist/gateway/oversized-reply.d.ts +39 -0
  56. package/dist/gateway/oversized-reply.d.ts.map +1 -0
  57. package/dist/gateway/oversized-reply.js +54 -0
  58. package/dist/gateway/oversized-reply.js.map +1 -0
  59. package/dist/gateway/registry.d.ts +0 -10
  60. package/dist/gateway/registry.d.ts.map +1 -1
  61. package/dist/gateway/registry.js +148 -4
  62. package/dist/gateway/registry.js.map +1 -1
  63. package/dist/inference/route-resolver.d.ts +39 -0
  64. package/dist/inference/route-resolver.d.ts.map +1 -1
  65. package/dist/inference/route-resolver.js +51 -2
  66. package/dist/inference/route-resolver.js.map +1 -1
  67. package/dist/inference/thinking-replay.d.ts +86 -0
  68. package/dist/inference/thinking-replay.d.ts.map +1 -0
  69. package/dist/inference/thinking-replay.js +110 -0
  70. package/dist/inference/thinking-replay.js.map +1 -0
  71. package/dist/inference/tools.d.ts +9 -0
  72. package/dist/inference/tools.d.ts.map +1 -1
  73. package/dist/inference/tools.js +12 -1
  74. package/dist/inference/tools.js.map +1 -1
  75. package/dist/learning/assessment-protocol.d.ts +120 -0
  76. package/dist/learning/assessment-protocol.d.ts.map +1 -0
  77. package/dist/learning/assessment-protocol.js +236 -0
  78. package/dist/learning/assessment-protocol.js.map +1 -0
  79. package/dist/learning/assessment-turn-context.d.ts +54 -0
  80. package/dist/learning/assessment-turn-context.d.ts.map +1 -0
  81. package/dist/learning/assessment-turn-context.js +71 -0
  82. package/dist/learning/assessment-turn-context.js.map +1 -0
  83. package/dist/learning/exhaustion-report.d.ts +24 -1
  84. package/dist/learning/exhaustion-report.d.ts.map +1 -1
  85. package/dist/learning/exhaustion-report.js +18 -6
  86. package/dist/learning/exhaustion-report.js.map +1 -1
  87. package/dist/learning/perf-probe.d.ts +140 -0
  88. package/dist/learning/perf-probe.d.ts.map +1 -0
  89. package/dist/learning/perf-probe.js +316 -0
  90. package/dist/learning/perf-probe.js.map +1 -0
  91. package/dist/learning/quota-ledger.d.ts +24 -0
  92. package/dist/learning/quota-ledger.d.ts.map +1 -1
  93. package/dist/learning/quota-ledger.js +40 -0
  94. package/dist/learning/quota-ledger.js.map +1 -1
  95. package/dist/learning/runtime-dependencies.d.ts +183 -0
  96. package/dist/learning/runtime-dependencies.d.ts.map +1 -0
  97. package/dist/learning/runtime-dependencies.js +349 -0
  98. package/dist/learning/runtime-dependencies.js.map +1 -0
  99. package/dist/learning/self-check-discovery.d.ts +117 -0
  100. package/dist/learning/self-check-discovery.d.ts.map +1 -0
  101. package/dist/learning/self-check-discovery.js +334 -0
  102. package/dist/learning/self-check-discovery.js.map +1 -0
  103. package/dist/learning/skill-store.d.ts.map +1 -1
  104. package/dist/learning/skill-store.js +6 -5
  105. package/dist/learning/skill-store.js.map +1 -1
  106. package/dist/skills/bundled-skills-phase3.d.ts +14 -0
  107. package/dist/skills/bundled-skills-phase3.d.ts.map +1 -1
  108. package/dist/skills/bundled-skills-phase3.js +50 -0
  109. package/dist/skills/bundled-skills-phase3.js.map +1 -1
  110. package/dist/tools/child-agent-runtime.js +19 -1
  111. package/dist/tools/child-agent-runtime.js.map +1 -1
  112. package/dist/tools/credentials-tool.d.ts.map +1 -1
  113. package/dist/tools/credentials-tool.js +4 -1
  114. package/dist/tools/credentials-tool.js.map +1 -1
  115. package/dist/tools/loop-project-context.d.ts +7 -0
  116. package/dist/tools/loop-project-context.d.ts.map +1 -1
  117. package/dist/tools/loop-project-context.js +44 -0
  118. package/dist/tools/loop-project-context.js.map +1 -1
  119. package/dist/tools/modality/motion-presets.d.ts +62 -0
  120. package/dist/tools/modality/motion-presets.d.ts.map +1 -0
  121. package/dist/tools/modality/motion-presets.js +399 -0
  122. package/dist/tools/modality/motion-presets.js.map +1 -0
  123. package/dist/tools/modality/motion-tool.d.ts +62 -0
  124. package/dist/tools/modality/motion-tool.d.ts.map +1 -0
  125. package/dist/tools/modality/motion-tool.js +220 -0
  126. package/dist/tools/modality/motion-tool.js.map +1 -0
  127. package/dist/tools/modality/motion.d.ts +298 -0
  128. package/dist/tools/modality/motion.d.ts.map +1 -0
  129. package/dist/tools/modality/motion.js +514 -0
  130. package/dist/tools/modality/motion.js.map +1 -0
  131. package/dist/tools/pipeline-tool.d.ts.map +1 -1
  132. package/dist/tools/pipeline-tool.js +19 -1
  133. package/dist/tools/pipeline-tool.js.map +1 -1
  134. package/dist/tools/plan-store.d.ts +90 -6
  135. package/dist/tools/plan-store.d.ts.map +1 -1
  136. package/dist/tools/plan-store.js +140 -13
  137. package/dist/tools/plan-store.js.map +1 -1
  138. package/dist/tools/publish-tool.d.ts.map +1 -1
  139. package/dist/tools/publish-tool.js +9 -0
  140. package/dist/tools/publish-tool.js.map +1 -1
  141. package/dist/tools/registry.d.ts.map +1 -1
  142. package/dist/tools/registry.js +100 -3
  143. package/dist/tools/registry.js.map +1 -1
  144. package/dist/tools/tool-loop.d.ts +17 -0
  145. package/dist/tools/tool-loop.d.ts.map +1 -1
  146. package/dist/tools/tool-loop.js +70 -17
  147. package/dist/tools/tool-loop.js.map +1 -1
  148. package/dist/tools/toolsets.js +2 -2
  149. package/dist/tools/toolsets.js.map +1 -1
  150. package/dist/utils/workspace-need.d.ts +84 -0
  151. package/dist/utils/workspace-need.d.ts.map +1 -0
  152. package/dist/utils/workspace-need.js +227 -0
  153. package/dist/utils/workspace-need.js.map +1 -0
  154. package/dist/web-dashboard/chat-console.d.ts +22 -0
  155. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  156. package/dist/web-dashboard/chat-console.js +75 -11
  157. package/dist/web-dashboard/chat-console.js.map +1 -1
  158. package/dist/web-dashboard/hub-data.d.ts +9 -1
  159. package/dist/web-dashboard/hub-data.d.ts.map +1 -1
  160. package/dist/web-dashboard/hub-data.js +2 -0
  161. package/dist/web-dashboard/hub-data.js.map +1 -1
  162. package/dist/web-dashboard/project-context.d.ts +1 -1
  163. package/dist/web-dashboard/project-context.d.ts.map +1 -1
  164. package/dist/web-dashboard/project-context.js +13 -1
  165. package/dist/web-dashboard/project-context.js.map +1 -1
  166. package/dist/web-dashboard/server.d.ts +33 -0
  167. package/dist/web-dashboard/server.d.ts.map +1 -1
  168. package/dist/web-dashboard/server.js +229 -24
  169. package/dist/web-dashboard/server.js.map +1 -1
  170. package/dist/web-dashboard/src/types.d.ts +46 -0
  171. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  172. package/dist/web-dashboard/workspace-guard.d.ts +20 -21
  173. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  174. package/dist/web-dashboard/workspace-guard.js +20 -153
  175. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  176. package/dist/web-dashboard/workspace-resolution.d.ts +23 -5
  177. package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -1
  178. package/dist/web-dashboard/workspace-resolution.js +22 -4
  179. package/dist/web-dashboard/workspace-resolution.js.map +1 -1
  180. package/package.json +1 -1
  181. package/src/web-dashboard/public/assets/index-CGYIlLpO.js +236 -0
  182. package/src/web-dashboard/public/assets/index-CGYIlLpO.js.map +1 -0
  183. package/src/web-dashboard/public/assets/{index-DeSsNU94.css → index-CzRU84ET.css} +1 -1
  184. package/src/web-dashboard/public/index.html +2 -2
  185. package/src/web-dashboard/public/assets/index-Do_O67Kv.js +0 -236
  186. package/src/web-dashboard/public/assets/index-Do_O67Kv.js.map +0 -1
@@ -25,12 +25,13 @@ 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';
31
32
  import { readDecisions, recallDecisions, reviseDecision } from '../learning/decision-log.js';
32
33
  import { degradedCallsFromTraces } from '../learning/reasoning-trace.js';
33
- import { withStrictModel } from '../inference/route-resolver.js';
34
+ import { strictPinPolicy, withStrictModel } from '../inference/route-resolver.js';
34
35
  import { readRecallHits } from '../context/session-recall.js';
35
36
  import { getRouterPromotion } from '../learning/router-promotion.js';
36
37
  import { AUTH_CLEAR_THRESHOLD } from '../learning/key-hygiene.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
  }
@@ -7533,7 +7654,24 @@ function handleRequest(req, res) {
7533
7654
  // only" switch). Scoped to the turn with AsyncLocalStorage rather than a
7534
7655
  // process env write, because two sessions can run concurrently and a
7535
7656
  // global mutation would leak one user's pin into the other's turn.
7536
- const strict = body?.strict === true;
7657
+ //
7658
+ // READ AS A TRI-STATE (2026-10-16) — same three cases, same reasoning, as
7659
+ // `worktree`/`resume` below. `true` holds the pin; `false` is this turn's
7660
+ // EXPLICIT permission to substitute; ABSENT defers to the pin's own policy,
7661
+ // where a request that NAMED a provider+model HOLDS (the pair `pinOf`
7662
+ // records) and an Auto turn has no pair to hold.
7663
+ //
7664
+ // Collapsing absent into `false` — the previous `body?.strict === true` —
7665
+ // was wrong on both sides. A pinned API request that never mentioned the
7666
+ // flag was substituted by default, i.e. a pin read as a preference, which
7667
+ // is the contract `strictPinPolicy` exists to end. And because the scope
7668
+ // set here OUTRANKS the environment inside `withStrictModel`, a dashboard
7669
+ // deployment running with `NUVIRA_STRICT_MODEL=1` was silently un-strict
7670
+ // for every chat turn that left the flag out — the operator's own switch,
7671
+ // quietly ignored.
7672
+ const strict = typeof body?.strict === 'boolean'
7673
+ ? body.strict
7674
+ : strictPinPolicy(typeof provider === 'string' && typeof model === 'string');
7537
7675
  // WS5 (#27) — per-turn isolation and resume, from the GUI.
7538
7676
  //
7539
7677
  // Read as a strict TRI-state, because the three cases are genuinely
@@ -7598,13 +7736,51 @@ function handleRequest(req, res) {
7598
7736
  */
7599
7737
  const configuredCwd = resolveDashboardCwd();
7600
7738
  const { resolveTurnWorkspace, formatWorkspaceNoticeText } = await import('./workspace-resolution.js');
7739
+ const { workspaceNeedForMissingFolder } = await import('./workspace-guard.js');
7601
7740
  const sessionProjectPath = chatConsole.get(sessionId)?.projectPath;
7602
- const workspace = resolveTurnWorkspace({
7741
+ /**
7742
+ * The same ladder, asked twice when the last rung is needed.
7743
+ *
7744
+ * WHY TWO PASSES. The scratch folder is a real directory on disk, so it is
7745
+ * only created when the ladder has actually RUN OUT (nothing attached,
7746
+ * nothing named in the message, no configured default) AND the ask needs
7747
+ * somewhere to PUT new work. Creating it eagerly would leave an empty
7748
+ * temporary folder behind on every turn that already had a folder, and
7749
+ * handing it to a READ ask would be the kuttaaddon defect again — an empty
7750
+ * folder cannot answer "assess this project".
7751
+ */
7752
+ const resolveWorkspaceNow = (scratchPath) => resolveTurnWorkspace({
7603
7753
  ...(attachedPath ? { attachedPath } : {}),
7604
7754
  ...(sessionProjectPath ? { sessionPath: sessionProjectPath } : {}),
7605
7755
  ...(configuredCwd ? { configuredCwd } : {}),
7606
7756
  message,
7757
+ ...(scratchPath ? { scratchPath } : {}),
7607
7758
  });
7759
+ let workspace = resolveWorkspaceNow();
7760
+ /**
7761
+ * What this ask needs a folder FOR — `'produce'` is the case a temporary
7762
+ * folder can serve, `'read'` is the case it cannot (and keeps the folder
7763
+ * prompt), `null` is plain chat, which needs none.
7764
+ */
7765
+ const missingWorkspaceNeed = workspace.path ? null : workspaceNeedForMissingFolder(message);
7766
+ if (missingWorkspaceNeed === 'produce') {
7767
+ // The scratch folder is offered through the SAME rule the gateway uses
7768
+ // (`scratchWorkspaceForTurn`), so the two surfaces cannot disagree about
7769
+ // when a temporary folder is the right answer — in particular about an
7770
+ // ISOLATED turn, which a scratch folder can never serve: it has no base
7771
+ // commit to diff against. There, this turn has no folder at all, so it
7772
+ // falls through to the folder request below rather than isolating a
7773
+ // directory the user never chose.
7774
+ const { scratchWorkspaceForTurn } = await import('../config/scratch-workspace.js');
7775
+ const { resolveIsolationRequest } = await import('../tools/worktree.js');
7776
+ const scratch = scratchWorkspaceForTurn({
7777
+ key: sessionId,
7778
+ need: missingWorkspaceNeed,
7779
+ isolationAsked: resolveIsolationRequest({ worktree }).asked,
7780
+ });
7781
+ if (scratch)
7782
+ workspace = resolveWorkspaceNow(scratch);
7783
+ }
7608
7784
  if (workspace.path) {
7609
7785
  projectPath = workspace.path;
7610
7786
  const bundle = getProjectBundle(workspace.path);
@@ -7622,24 +7798,23 @@ function handleRequest(req, res) {
7622
7798
  * needs no caption.
7623
7799
  */
7624
7800
  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
- }
7801
+ // Still no workspace: either the ask must READ or CHANGE work that already
7802
+ // exists (a temporary folder holds none of it), or the scratch folder could
7803
+ // not be created. Both are the case for a folder request rather than an
7804
+ // answer about the wrong tree. General chat — `null` — is unaffected.
7805
+ if (!workspace.path && missingWorkspaceNeed !== null) {
7806
+ const { projectAttachmentPrompt, projectAttachmentFollowups } = await import('./workspace-guard.js');
7807
+ writeJson(res, 200, {
7808
+ ok: true,
7809
+ sessionId,
7810
+ content: projectAttachmentPrompt(),
7811
+ followups: projectAttachmentFollowups(),
7812
+ provider: null,
7813
+ model: null,
7814
+ generationFailed: false,
7815
+ needsProject: true,
7816
+ });
7817
+ return;
7643
7818
  }
7644
7819
  // P4 (revised) — a client DISCONNECT no longer cancels the turn. It used
7645
7820
  // to: the server treated `res` close as an abort, which meant a page
@@ -7677,6 +7852,14 @@ function handleRequest(req, res) {
7677
7852
  const result = await withStrictModel(strict, () => chatConsole.answer(sessionId, message, {
7678
7853
  provider,
7679
7854
  model,
7855
+ // P8b — the STRICT half of the pin rides into the console so the
7856
+ // CONVERSATION can remember it. `withStrictModel` above scopes it to
7857
+ // this turn, but a conversation whose lock is forgotten between turns
7858
+ // is a pin the user cannot actually rely on: measured live
7859
+ // (trace-1792068695325-42wb1j) the pin survived a reload and the lock
7860
+ // did not, so the engine handed the turn to another model with the
7861
+ // user's "this model only" instruction never in force.
7862
+ strict,
7680
7863
  projectContext,
7681
7864
  projectPath,
7682
7865
  // Cluster G — carry the "no folder attached" state into the turn so a
@@ -7742,10 +7925,20 @@ function handleRequest(req, res) {
7742
7925
  `your pinned ${requested} is ${pinnedClass} class, and this turn ran on ${served} (${servedClass} class).`;
7743
7926
  }
7744
7927
  else if (substituted) {
7745
- 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.`;
7928
+ // Reachable now only when the turn EXPLICITLY allowed a substitute
7929
+ // (`strict: false`, the chat's lock switched off) or the deployment set
7930
+ // `NUVIRA_STRICT_MODEL=0`, because a named pair holds by default. So the
7931
+ // instruction names the two controls that actually release it, instead
7932
+ // of telling the user to enable something that is already the default.
7933
+ routingNotice = `Auto routing took over: this turn ran on ${served} because your pinned ${requested} was not available. To work with ${requested} only, hold the pin: turn the chat's lock back on, or set NUVIRA_STRICT_MODEL=1.`;
7746
7934
  }
7747
7935
  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.`;
7936
+ // The turn ran on the pair the user pinned and stopped there. Saying
7937
+ // "auto routing may hand the work to another model" described the old
7938
+ // behaviour (a pinned turn followed the health ranking the moment its
7939
+ // pair returned an error) and would now be a lie: a pin waits on itself
7940
+ // and then asks. See `pinnedEscalation`.
7941
+ 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
7942
  }
7750
7943
  }
7751
7944
  // Did the offer we are about to send get a mechanism behind it? The client
@@ -8348,6 +8541,18 @@ export function createDashboardServer(opts) {
8348
8541
  handle.ipv6Twin = undefined;
8349
8542
  }
8350
8543
  }
8544
+ // Scratch workspaces are temporary BY DEFINITION, so the ones nobody came back
8545
+ // to are cleaned on boot rather than accumulating in the user's home for the
8546
+ // life of the install (see `pruneScratchWorkspaces`). Best-effort: a failed
8547
+ // prune must never stop the server from coming up.
8548
+ try {
8549
+ const removed = pruneScratchWorkspaces();
8550
+ if (removed > 0)
8551
+ console.log(` 🧹 Removed ${removed} expired scratch workspace(s)`);
8552
+ }
8553
+ catch {
8554
+ /* best-effort */
8555
+ }
8351
8556
  server.listen(bindPort, bindHost, () => {
8352
8557
  console.log(`\n 🌐 Agent-Nuvira Dashboard`);
8353
8558
  console.log(` ─────────────────────────`);