dsh-openwrite 0.2.28 → 0.2.34

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 (125) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +49 -8
  3. package/THIRD_PARTY_NOTICES.md +4 -3
  4. package/companions.mjs +508 -0
  5. package/cordis.patch.yml +0 -2
  6. package/docs/CLOSED_ISSUE_FOLLOWUPS.md +1 -1
  7. package/docs/COMPANION_PLUGINS.md +142 -0
  8. package/docs/CONTEXT_SLOTS.md +1 -1
  9. package/docs/COST_MEASUREMENT.md +2 -2
  10. package/docs/DEVELOPMENT.md +6 -11
  11. package/docs/DSH_STORE.md +14 -2
  12. package/docs/EDITOR_SAFETY_REPAIRS.md +2 -2
  13. package/docs/INSTALL.md +112 -4
  14. package/docs/ISSUES_121_123.md +4 -4
  15. package/docs/ISSUES_140_142.md +58 -0
  16. package/docs/ISSUES_149_150_151.md +77 -0
  17. package/docs/ISSUE_123_FOLLOWUP.md +3 -2
  18. package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
  19. package/docs/LEARNING.md +1 -1
  20. package/docs/MOBILE_READINESS.md +62 -0
  21. package/docs/PLUGIN_MAINTENANCE.md +1 -3
  22. package/docs/RELEASE_ACCEPTANCE.md +20 -3
  23. package/docs/REVIEW_ROUND_22.md +67 -0
  24. package/docs/REVIEW_ROUND_23.md +48 -0
  25. package/docs/REVIEW_ROUND_25.md +51 -0
  26. package/docs/SELECTION_UPGRADE_REPAIRS.md +2 -2
  27. package/docs/TOOLS.md +181 -604
  28. package/docs/WORKFLOWS.md +5 -5
  29. package/package.json +17 -21
  30. package/packages/openwrite-bridge/lib/client.d.ts +0 -1
  31. package/packages/openwrite-bridge/lib/client.js +90 -9
  32. package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
  33. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
  34. package/packages/openwrite-bridge/lib/dog-delivery.js +3 -1
  35. package/packages/openwrite-bridge/lib/dog-review.js +15 -3
  36. package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
  37. package/packages/openwrite-bridge/lib/domain.js +250 -29
  38. package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
  39. package/packages/openwrite-bridge/lib/host-llm.js +657 -0
  40. package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
  41. package/packages/openwrite-bridge/lib/host-web.js +166 -0
  42. package/packages/openwrite-bridge/lib/index.d.ts +4 -0
  43. package/packages/openwrite-bridge/lib/index.js +150 -6
  44. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
  45. package/packages/openwrite-bridge/lib/managed-runtime.js +176 -34
  46. package/packages/openwrite-bridge/lib/tool-policy.js +37 -12
  47. package/packages/openwrite-bridge/lib/tools.js +1223 -1227
  48. package/packages/openwrite-bridge/package.json +7 -5
  49. package/packages/studio-panel/lib/client.js +123207 -121534
  50. package/packages/studio-panel/lib/client.js.map +1 -1
  51. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
  52. package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
  53. package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
  54. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
  55. package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
  56. package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
  57. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
  58. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +1 -1
  59. package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
  60. package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
  61. package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
  62. package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
  63. package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
  64. package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
  65. package/packages/studio-panel/package.json +5 -3
  66. package/plugin.mjs +159 -20
  67. package/preset-definition.mjs +144 -4
  68. package/presets/openwrite/agent.cordis.yml +7 -9
  69. package/presets/openwrite/ops-integration.mjs +28 -0
  70. package/presets/openwrite/preset.yml +2 -1
  71. package/presets/openwrite/skills/canon/SKILL.md +3 -3
  72. package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
  73. package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
  74. package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
  75. package/presets/openwrite/skills/export-book/SKILL.md +3 -3
  76. package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
  77. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
  78. package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
  79. package/presets/openwrite/skills/learn/SKILL.md +3 -3
  80. package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
  81. package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
  82. package/presets/openwrite/skills/progress/SKILL.md +1 -1
  83. package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
  84. package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
  85. package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
  86. package/presets/openwrite/skills/style-system/SKILL.md +13 -6
  87. package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
  88. package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
  89. package/presets/openwrite/skills/world-query/SKILL.md +16 -11
  90. package/presets/openwrite/skills/write-next/SKILL.md +1 -1
  91. package/release/{openwrite-5.8.20-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
  92. package/release/runtime-manifest.json +4 -13
  93. package/release/source.json +21 -21
  94. package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
  95. package/scripts/maintenance.mjs +67 -7
  96. package/scripts/plugin-doctor.mjs +179 -0
  97. package/scripts/prepare.mjs +26 -4
  98. package/scripts/safe-upgrade.mjs +38 -5
  99. package/dog-tools.mjs +0 -13
  100. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
  101. package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
  102. package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
  103. package/scripts/dog/delivery-stage.js +0 -32
  104. package/scripts/dog/import-chapter.js +0 -20
  105. package/scripts/dog/import-record.js +0 -35
  106. package/scripts/dog/review-dimension.js +0 -41
  107. package/scripts/dog/review-record.js +0 -42
  108. package/vendor/dsh-dog/LICENSE +0 -29
  109. package/vendor/dsh-dog/README.md +0 -114
  110. package/vendor/dsh-dog/UPSTREAM.md +0 -30
  111. package/vendor/dsh-dog/cordis.patch.yml +0 -14
  112. package/vendor/dsh-dog/lib/client.js +0 -2960
  113. package/vendor/dsh-dog/lib/client.js.map +0 -1
  114. package/vendor/dsh-dog/lib/core--byUPJL8.js +0 -2191
  115. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
  116. package/vendor/dsh-dog/lib/core.d.ts +0 -2
  117. package/vendor/dsh-dog/lib/core.js +0 -2
  118. package/vendor/dsh-dog/lib/index.d.ts +0 -143
  119. package/vendor/dsh-dog/lib/index.js +0 -1230
  120. package/vendor/dsh-dog/package.json +0 -110
  121. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
  122. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
  123. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
  124. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
  125. package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
@@ -13,7 +13,7 @@ export class UnsupportedRuntimePlatformError extends Error {
13
13
  code = 'RUNTIME_PLATFORM_UNSUPPORTED';
14
14
  status = 503;
15
15
  constructor(platform) {
16
- super(`OpenWrite Studio managed runtime is unavailable on ${platform}. Configure the bridge with mode: external and baseUrl pointing to a supported Studio server; see docs/INSTALL.md#external-studio. File tools that do not require Studio remain available.`);
16
+ super(`OpenWrite Studio managed runtime is unavailable on ${platform}. Set pythonPath or OPENWRITE_PYTHON to a prepared Python interpreter, or configure the bridge with mode: external and baseUrl pointing to a supported Studio server; see docs/INSTALL.md#external-studio. File tools that do not require Studio remain available.`);
17
17
  this.platform = platform;
18
18
  this.name = 'UnsupportedRuntimePlatformError';
19
19
  }
@@ -22,6 +22,8 @@ export const DEFAULT_RESTART_LIMIT = 5;
22
22
  export const DEFAULT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000];
23
23
  /** Budget for the per-request liveness probe of a previously-ready backend. */
24
24
  const HEALTH_PROBE_TIMEOUT_MS = 1_500;
25
+ /** Second, longer budget for an ambiguous probe before calling it `unknown`. */
26
+ const HEALTH_PROBE_RETRY_MS = 8_000;
25
27
  /** A download with no progress for this long is cut and retried on the next source. */
26
28
  const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
27
29
  /** The backend stderr log on disk keeps only the newest chunk. */
@@ -181,9 +183,17 @@ export class ManagedRuntime {
181
183
  wait;
182
184
  restartLimit;
183
185
  backoffMs;
186
+ pythonPath;
187
+ pythonModulePaths;
188
+ hostLlm;
189
+ hostWeb;
184
190
  constructor(root, artifacts, options = {}) {
185
191
  this.root = root;
186
192
  this.artifacts = artifacts;
193
+ this.pythonPath = options.pythonPath?.trim() || process.env['OPENWRITE_PYTHON']?.trim() || undefined;
194
+ this.pythonModulePaths = [...(options.pythonModulePaths ?? [])];
195
+ this.hostLlm = options.hostLlm;
196
+ this.hostWeb = options.hostWeb;
187
197
  this.spawnFn = options.spawn ?? spawn;
188
198
  this.wait = options.delay ?? delay;
189
199
  this.restartLimit = options.restartLimit ?? DEFAULT_RESTART_LIMIT;
@@ -241,7 +251,12 @@ export class ManagedRuntime {
241
251
  * forcefully and pushed through the normal recovery path.
242
252
  */
243
253
  async verifyConnection(connection) {
244
- if (await this.probeHealth(connection)) {
254
+ const liveness = await this.probeLiveness(connection);
255
+ // Only a *confirmed* dead backend justifies tearing it down. A probe that
256
+ // merely timed out is ambiguous — a backend busy running chapter_write or
257
+ // review answers /api/health late — and killing it destroys exactly the
258
+ // in-flight work this recovery path exists to protect.
259
+ if (liveness === 'alive') {
245
260
  if (this.closed)
246
261
  throw new Error('OpenWrite was removed');
247
262
  const current = this.connection;
@@ -249,6 +264,14 @@ export class ManagedRuntime {
249
264
  return current;
250
265
  return this.queueRestart();
251
266
  }
267
+ if (liveness === 'unknown') {
268
+ // Keep serving the cached connection and let the real request decide; a
269
+ // slow health answer is not a reason to invalidate a working backend.
270
+ const current = this.connection;
271
+ if (current)
272
+ return current;
273
+ return this.queueRestart();
274
+ }
252
275
  if (this.connection !== connection)
253
276
  return this.queueRestart();
254
277
  this.connection = undefined;
@@ -263,22 +286,76 @@ export class ManagedRuntime {
263
286
  this.update('recovering', 'The writing backend is unresponsive; recovering');
264
287
  return this.queueRestart();
265
288
  }
266
- /** Short-timeout liveness probe; false covers exit, refusal, hang, and auth drift. */
267
- async probeHealth(connection) {
268
- const child = this.child;
269
- if (!child || child.exitCode !== null || child.signalCode !== null)
270
- return false;
289
+ /**
290
+ * The host relay coordinates, if the host offers one.
291
+ *
292
+ * A relay that cannot be reached is not a startup failure: Core still runs,
293
+ * it simply has no host models to offer. The failure is silent on purpose —
294
+ * the relay's own diagnostics belong to the host, not to the backend boot.
295
+ */
296
+ async resolveHostLlm() {
297
+ if (this.hostLlm === undefined)
298
+ return undefined;
271
299
  try {
272
- const response = await fetch(connection.baseUrl + '/api/health', {
273
- headers: { Authorization: `Bearer ${connection.token}` },
274
- signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS),
275
- });
276
- return response.ok;
300
+ const resolved = await this.hostLlm();
301
+ if (resolved === undefined || resolved === null)
302
+ return undefined;
303
+ if (typeof resolved.url !== 'string' || resolved.url === '')
304
+ return undefined;
305
+ if (typeof resolved.token !== 'string' || resolved.token === '')
306
+ return undefined;
307
+ return { url: resolved.url, token: resolved.token };
277
308
  }
278
309
  catch {
279
- return false;
310
+ return undefined;
280
311
  }
281
312
  }
313
+ async resolveHostWeb() {
314
+ if (this.hostWeb === undefined)
315
+ return undefined;
316
+ try {
317
+ const resolved = await this.hostWeb();
318
+ if (resolved === undefined)
319
+ return undefined;
320
+ return { url: resolved.url, token: resolved.token };
321
+ }
322
+ catch {
323
+ return undefined;
324
+ }
325
+ }
326
+ /**
327
+ * Liveness with three outcomes. `dead` is only returned on positive evidence —
328
+ * the child exited, or the connection was refused, or health answered
329
+ * non-ok (auth drift). A probe that timed out returns `unknown`, because a
330
+ * busy backend is indistinguishable from a hung one at a 1.5 s deadline and
331
+ * killing the former destroys in-flight work.
332
+ */
333
+ async probeLiveness(connection) {
334
+ const child = this.child;
335
+ if (!child || child.exitCode !== null || child.signalCode !== null)
336
+ return 'dead';
337
+ for (const budget of [HEALTH_PROBE_TIMEOUT_MS, HEALTH_PROBE_RETRY_MS]) {
338
+ try {
339
+ const response = await fetch(connection.baseUrl + '/api/health', {
340
+ headers: { Authorization: `Bearer ${connection.token}` },
341
+ signal: AbortSignal.timeout(budget),
342
+ });
343
+ return response.ok ? 'alive' : 'dead';
344
+ }
345
+ catch (error) {
346
+ // A refusal or a dead socket is positive evidence; anything else (the
347
+ // timeout we just imposed, DNS, reset) is not.
348
+ const code = error.code
349
+ ?? error.cause?.code;
350
+ if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN')
351
+ return 'dead';
352
+ if (error instanceof Error && error.name === 'TimeoutError')
353
+ continue;
354
+ return 'dead';
355
+ }
356
+ }
357
+ return 'unknown';
358
+ }
282
359
  async cancel() {
283
360
  this.cancelEpoch += 1;
284
361
  this.allowRevive = false;
@@ -530,7 +607,7 @@ export class ManagedRuntime {
530
607
  async command(executable, args, signal) {
531
608
  signal.throwIfAborted();
532
609
  const child = this.spawnFn(executable, args, { cwd: this.root, detached: process.platform !== 'win32', windowsHide: true,
533
- env: buildChildEnv(process.env, { UV_CACHE_DIR: join(this.root, 'cache', 'uv'), UV_PYTHON_DOWNLOADS: 'never', UV_NO_CONFIG: '1' }),
610
+ env: buildChildEnv(process.env, { ...this.pythonLibraryEnv(), UV_CACHE_DIR: join(this.root, 'cache', 'uv'), UV_PYTHON_DOWNLOADS: 'never', UV_NO_CONFIG: '1' }),
534
611
  stdio: ['ignore', 'ignore', 'pipe'] });
535
612
  let failure = '';
536
613
  child.stderr?.on('data', bytes => { failure = (failure + String(bytes)).slice(-2000); });
@@ -547,6 +624,10 @@ export class ManagedRuntime {
547
624
  signal.removeEventListener('abort', abort);
548
625
  }
549
626
  }
627
+ /** Shared Android/embedded Python may need its host's native-library search paths. */
628
+ pythonLibraryEnv() {
629
+ return this.pythonPath ? Object.fromEntries(['LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'DYLD_FALLBACK_LIBRARY_PATH'].map(key => [key, process.env[key]])) : {};
630
+ }
550
631
  async lock(signal) {
551
632
  for (;;) {
552
633
  signal.throwIfAborted();
@@ -567,7 +648,13 @@ export class ManagedRuntime {
567
648
  async prepare(signal) {
568
649
  const manifest = JSON.parse(await readFile(join(this.artifacts, 'runtime-manifest.json'), 'utf8'));
569
650
  const platform = manifest.platforms[`${process.platform}-${process.arch}`];
570
- if (!platform)
651
+ if (this.pythonPath && !isAbsolute(this.pythonPath))
652
+ throw new Error('pythonPath / OPENWRITE_PYTHON must be an absolute path to a Python executable, without command-line arguments');
653
+ if (this.pythonModulePaths.length && !this.pythonPath)
654
+ throw new Error('pythonModulePaths requires pythonPath or OPENWRITE_PYTHON');
655
+ if (this.pythonModulePaths.some(path => !isAbsolute(path)))
656
+ throw new Error('pythonModulePaths must contain absolute dependency directory paths');
657
+ if (!this.pythonPath && !platform)
571
658
  throw new UnsupportedRuntimePlatformError(`${process.platform}/${process.arch}`);
572
659
  if (!manifest.wheel || !manifest.requirements)
573
660
  throw new Error('The package is missing Core or dependency checksums');
@@ -584,30 +671,73 @@ export class ManagedRuntime {
584
671
  const unlock = await this.lock(signal);
585
672
  let python;
586
673
  try {
587
- const generation = createHash('sha256').update(JSON.stringify(manifest)).digest('hex').slice(0, 24);
674
+ const generation = createHash('sha256').update(JSON.stringify(manifest))
675
+ .update(this.pythonPath ? JSON.stringify({ pythonPath: this.pythonPath, pythonModulePaths: this.pythonModulePaths }) : '').digest('hex').slice(0, 24);
588
676
  const destination = join(this.root, 'environments', generation);
589
- python = join(destination, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
590
- if (await readFile(join(destination, '.complete'), 'utf8').catch(() => '') !== generation) {
591
- const uv = await this.extract(platform.uv, signal);
592
- const basePython = await this.extract(platform.python, signal);
593
- this.update('installing', 'Installing isolated dependencies; first use may take several minutes');
594
- await rm(destination, { recursive: true, force: true });
677
+ let coreRoot;
678
+ if (this.pythonPath) {
679
+ python = this.pythonPath;
680
+ coreRoot = join(destination, 'core');
681
+ const complete = await readFile(join(destination, '.complete'), 'utf8').catch(() => '') === generation;
595
682
  try {
596
- await this.command(uv, ['venv', '--python', basePython, destination], signal);
597
- await this.command(uv, ['pip', 'sync', '--python', python, '--require-hashes', '--only-binary', ':all:',
598
- ...(manifest.dependency_wheels?.length ? ['--find-links', join(this.artifacts, 'wheels')] : []), requirements], signal);
599
- await this.command(uv, ['pip', 'install', '--python', python, '--no-deps', wheel], signal);
600
- await this.command(python, ['-I', '-c', 'from tools.studio_http import health_payload; from tools.studio import create_server; assert health_payload()["contract_version"] == 1'], signal);
601
- await writeFile(join(destination, '.complete'), generation);
683
+ if (!complete) {
684
+ await rm(destination, { recursive: true, force: true });
685
+ await mkdir(coreRoot, { recursive: true });
686
+ // Core is a pure-Python wheel. Install only its verified contents in
687
+ // our directory: no pip/uv, dependency resolution, or author-site writes.
688
+ for (const [name, bytes] of Object.entries(unzipSync(await readFile(wheel)))) {
689
+ signal.throwIfAborted();
690
+ const target = resolve(coreRoot, name);
691
+ if (isAbsolute(name) || !target.startsWith(coreRoot + sep))
692
+ throw new Error('Invalid Core wheel path');
693
+ if (name.endsWith('/'))
694
+ await mkdir(target, { recursive: true });
695
+ else {
696
+ await mkdir(dirname(target), { recursive: true });
697
+ await writeFile(target, bytes);
698
+ }
699
+ }
700
+ }
701
+ this.update('installing', 'Checking the supplied Python and its dependencies; no runtime downloads');
702
+ await this.command(python, ['-I', '-B', '-X', 'utf8', '-c',
703
+ 'import json, sys; assert sys.version_info >= (3, 10), "Python >=3.10 is required"; sys.path[:0] = json.loads(sys.argv[1]); from tools.studio_http import health_payload; from tools.studio import create_server; h = health_payload(); assert h["core_version"] == sys.argv[2] and h["contract_version"] == int(sys.argv[3]), "Core version or API mismatch"',
704
+ JSON.stringify([coreRoot, ...this.pythonModulePaths]), manifest.core_version, String(manifest.contract_version)], AbortSignal.any([signal, AbortSignal.timeout(120_000)]));
705
+ if (!complete)
706
+ await writeFile(join(destination, '.complete'), generation);
602
707
  }
603
708
  catch (error) {
709
+ if (!complete)
710
+ await rm(destination, { recursive: true, force: true });
711
+ signal.throwIfAborted();
712
+ throw new Error(`Supplied Python is not ready: ${sanitizeDiagnostic(error instanceof Error ? error.message : String(error))}. Prepare its dependencies first; see docs/INSTALL.md#bring-your-own-python. No uv or Python download was attempted.`);
713
+ }
714
+ }
715
+ else {
716
+ if (!platform)
717
+ throw new UnsupportedRuntimePlatformError(`${process.platform}/${process.arch}`);
718
+ python = join(destination, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
719
+ if (await readFile(join(destination, '.complete'), 'utf8').catch(() => '') !== generation) {
720
+ const uv = await this.extract(platform.uv, signal);
721
+ const basePython = await this.extract(platform.python, signal);
722
+ this.update('installing', 'Installing isolated dependencies; first use may take several minutes');
604
723
  await rm(destination, { recursive: true, force: true });
605
- throw error;
724
+ try {
725
+ await this.command(uv, ['venv', '--python', basePython, destination], signal);
726
+ await this.command(uv, ['pip', 'sync', '--python', python, '--require-hashes', '--only-binary', ':all:',
727
+ ...(manifest.dependency_wheels?.length ? ['--find-links', join(this.artifacts, 'wheels')] : []), requirements], signal);
728
+ await this.command(uv, ['pip', 'install', '--python', python, '--no-deps', wheel], signal);
729
+ await this.command(python, ['-I', '-c', 'from tools.studio_http import health_payload; from tools.studio import create_server; assert health_payload()["contract_version"] == 1'], signal);
730
+ await writeFile(join(destination, '.complete'), generation);
731
+ }
732
+ catch (error) {
733
+ await rm(destination, { recursive: true, force: true });
734
+ throw error;
735
+ }
606
736
  }
607
737
  }
608
738
  signal.throwIfAborted();
609
739
  this.update(this.recovered ? 'recovering' : 'starting', this.recovered ? 'Recovering the writing environment' : 'Starting the writing environment');
610
- const connection = await this.start(python, manifest, signal);
740
+ const connection = await this.start(python, manifest, signal, coreRoot);
611
741
  const pending = join(this.root, 'active.' + randomUUID() + '.json');
612
742
  try {
613
743
  await writeFile(pending, JSON.stringify({ generation, coreVersion: manifest.core_version }));
@@ -628,16 +758,19 @@ export class ManagedRuntime {
628
758
  await unlock();
629
759
  }
630
760
  }
631
- async start(python, manifest, signal) {
761
+ async start(python, manifest, signal, coreRoot) {
632
762
  signal.throwIfAborted();
633
763
  const token = randomBytes(32).toString('hex');
634
764
  const generation = ++this.generation;
635
765
  // Windows defaults redirected stdout to its legacy locale encoding. Core
636
766
  // initialization logs and manuscript paths are Unicode; -I ignores Python
637
767
  // environment options, so set UTF-8 explicitly on the interpreter as well.
638
- const child = this.spawnFn(python, ['-I', '-X', 'utf8', '-u', '-m', 'tools.managed_runtime'], {
768
+ const launch = coreRoot
769
+ ? ['-c', 'import json, runpy, sys; sys.path[:0] = json.loads(sys.argv[1]); sys.argv = sys.argv[2:]; runpy.run_module(sys.argv[0], run_name="__main__", alter_sys=True)', JSON.stringify([coreRoot, ...this.pythonModulePaths]), 'tools.managed_runtime']
770
+ : ['-m', 'tools.managed_runtime'];
771
+ const child = this.spawnFn(python, ['-I', ...(coreRoot ? ['-B'] : []), '-X', 'utf8', '-u', ...launch], {
639
772
  cwd: this.root, detached: process.platform !== 'win32', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
640
- env: buildChildEnv(process.env, { PYTHONNOUSERSITE: '1', PYTHONUTF8: '1' }),
773
+ env: buildChildEnv(process.env, { ...this.pythonLibraryEnv(), PYTHONNOUSERSITE: '1', PYTHONUTF8: '1' }),
641
774
  });
642
775
  this.child = child;
643
776
  // Mirror stderr to state/logs/backend.log (last 64 KiB): a stuck backend
@@ -680,7 +813,16 @@ export class ManagedRuntime {
680
813
  signal.addEventListener('abort', abort, { once: true });
681
814
  let port;
682
815
  try {
683
- child.stdin.write(JSON.stringify({ token, state_dir: join(this.root, 'state') }) + '\n');
816
+ const handshake = { token, state_dir: join(this.root, 'state') };
817
+ // The relay address is ephemeral, so it is resolved per start and
818
+ // never cached: a stale URL would point Core at a dead port.
819
+ const hostLlm = await this.resolveHostLlm();
820
+ if (hostLlm !== undefined)
821
+ handshake['host_llm'] = hostLlm;
822
+ const hostWeb = await this.resolveHostWeb();
823
+ if (hostWeb !== undefined)
824
+ handshake['host_web'] = hostWeb;
825
+ child.stdin.write(JSON.stringify(handshake) + '\n');
684
826
  port = await ready;
685
827
  }
686
828
  finally {
@@ -1,18 +1,19 @@
1
+ // Only unconditionally read-only tools belong here. An action-dispatching tool
2
+ // must NOT be listed: `reads` short-circuits before `readActions`, so a merged
3
+ // family placed here has all of its mutating actions (delete, configure,
4
+ // save_routes) classified as reads and run under a read-only sandbox or plan
5
+ // mode. `novel_model_profiles` / `novel_embedding_profiles` were listed here by
6
+ // mistake after the 95→56 merge; their read-only actions are in `readActions`.
1
7
  const reads = new Set([
2
- 'novel_embedding_profiles', 'novel_status', 'novel_review_framework', 'novel_trace_list',
3
- 'novel_context_preview', 'novel_outline_read', 'novel_reading_order', 'novel_reading_packet',
4
- 'novel_chapter_work', 'novel_scene_structure', 'novel_chapter_scenes', 'novel_scene_migration_preview',
5
- 'novel_assets_list', 'novel_search', 'novel_doc_read', 'novel_asset_read', 'novel_revisions_list',
6
- 'novel_revision_get', 'novel_tasks_list', 'novel_continuity', 'novel_diagnostics',
7
- 'novel_research_status', 'novel_research_report', 'novel_model_profiles',
8
- 'novel_manuscript_acceptance', 'novel_export_preflight',
9
- 'novel_manuscript_metrics', 'novel_outline_audit',
8
+ 'novel_status', 'novel_review_framework', 'novel_trace_list',
9
+ 'novel_context_preview', 'novel_outline_read', 'novel_reading_packet',
10
+ 'novel_chapter_work', 'novel_chapter_scenes', 'novel_search', 'novel_continuity', 'novel_diagnostics',
11
+ 'novel_research_status', 'novel_research_report',
12
+ 'novel_manuscript_acceptance', 'novel_manuscript_metrics', 'novel_outline_audit',
10
13
  ]);
11
14
  const generates = new Set([
12
- 'novel_write_chapter', 'novel_review_chapter', 'novel_multi_write', 'novel_task_create',
13
- 'novel_task_retry', 'novel_task_confirm', 'novel_revision_create_selection',
14
- 'novel_revision_create_from_review', 'novel_revision_regenerate', 'novel_settle_backfill',
15
- 'novel_model_test', 'novel_model_embedding_test',
15
+ 'novel_write_chapter', 'novel_review_chapter', 'novel_multi_write',
16
+ 'novel_revision_regenerate', 'novel_settle_backfill',
16
17
  ]);
17
18
  const readActions = {
18
19
  novel_writing_assets: ['get'],
@@ -25,6 +26,28 @@ const readActions = {
25
26
  novel_reference_library_action: ['list', 'status', 'profile'],
26
27
  novel_runtime_skill_action: ['list', 'diagnose', 'resolve'],
27
28
  novel_rule_action: ['status'],
29
+ // Merged tools: the read-only actions of each family.
30
+ novel_task: ['get', 'list'],
31
+ novel_revision: ['list', 'get'],
32
+ novel_assets: ['list', 'read'],
33
+ novel_assets_package: ['preview'],
34
+ novel_doc: ['read'],
35
+ novel_scene: ['structure'],
36
+ novel_scene_migration: ['preview'],
37
+ novel_reading_order: ['get'],
38
+ novel_export: ['preflight'],
39
+ // `download` is NOT here: it writes a local file (mkdir + writeFile under
40
+ // outputDir), so classifying it as a read would let a read-only sandbox or plan
41
+ // mode write bytes to disk — the same class of mistake as the assets-package
42
+ // export. Its archive_op read variants are covered by the write classification
43
+ // being fail-closed rather than permissive.
44
+ novel_model_profiles: ['list', 'test'],
45
+ novel_embedding_profiles: ['list', 'test'],
46
+ };
47
+ /** Merged tools whose creating/generating actions replace the old per-tool names. */
48
+ const generateActions = {
49
+ novel_task: ['create', 'retry', 'confirm'],
50
+ novel_revision: ['create'],
28
51
  };
29
52
  /** Unknown and preview operations fail closed: previews can persist confirmation records. */
30
53
  export function novelEffect(name, args) {
@@ -37,6 +60,8 @@ export function novelEffect(name, args) {
37
60
  return 'read';
38
61
  if (name === 'novel_model_benchmark' && values.action === 'run')
39
62
  return 'generate';
63
+ if (generateActions[name]?.includes(String(values.action)))
64
+ return 'generate';
40
65
  return generates.has(name) ? 'generate' : 'write';
41
66
  }
42
67
  export function novelPolicyDenial(ctx, execution) {