dsh-openwrite 0.2.28 → 0.2.33

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 +11 -2
  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
@@ -1,5 +1,5 @@
1
1
  import { Service } from '@deepseek-ai/cordis';
2
- import { StudioClient, workspaceRootHeaders, workspaceRootsEqual, mutationAffectsState } from './client.js';
2
+ import { StudioClient, StudioError, workspaceRootHeaders, workspaceRootsEqual, mutationAffectsState } from './client.js';
3
3
  import { workspaceContextFromExec } from './tools.js';
4
4
  export const CONFIG_ROUTE = '/studio-panel/config.json';
5
5
  export const API_PROXY_ROUTE = '/studio-panel/api';
@@ -8,7 +8,8 @@ export const INVALIDATION_ROUTE = '/studio-panel/invalidation.json';
8
8
  const PROXY_TIMEOUT_MS = 60_000;
9
9
  /** Cap for the best-effort upstream context_epoch read; never blocks a snapshot longer. */
10
10
  const CONTEXT_EPOCH_TIMEOUT_MS = 5_000;
11
- const WRITABLE_PATHS = new Set([
11
+ /** Every browser write path the proxy forwards. Exported for the regression test. */
12
+ export const WRITABLE_PATHS = new Set([
12
13
  'writing-assets',
13
14
  'arc-reconcile',
14
15
  'assets',
@@ -56,11 +57,44 @@ const WRITABLE_PATHS = new Set([
56
57
  'model/embedding/delete',
57
58
  'model/profiles',
58
59
  'model/profiles/delete',
60
+ // The two-step delete the panel drives: preview first, then the real delete.
61
+ 'model/profiles/delete-preview',
59
62
  'model/routes',
63
+ // One profile that calls a model of the host's own directory.
64
+ 'model/host-profiles',
60
65
  'chapter/delete-batch',
61
66
  'research/settings',
67
+ 'research/preferences',
68
+ ]);
69
+ export const WRITABLE_PATH_RE = /^(?:tasks\/[A-Za-z0-9_-]+\/(?:cancel|retry|confirm)|revisions\/(?:selection|from-review|rev_[A-Za-z0-9_-]+\/(?:apply|reject|regenerate)))$/;
70
+ /**
71
+ * Paths whose payload is global, not project-scoped.
72
+ *
73
+ * The model profile library lives outside every project — Studio keeps it in
74
+ * the user's preferences directory (`model-profiles.json`), and every model
75
+ * route is registered with `requires_project=False`. The host settings dialog
76
+ * is the one surface that is opened *without* a bound workspace, so these
77
+ * paths must be reachable without a workspace id: the browser sends no
78
+ * `X-Dsh-Workspace-Id` and the proxy forwards the call without a project
79
+ * root, exactly as the contract's project-free routes expect.
80
+ *
81
+ * Every other proxied path stays fail-closed on a missing workspace id.
82
+ */
83
+ export const GLOBAL_PATHS = new Set([
84
+ 'model',
85
+ 'model/test',
86
+ 'model/embedding',
87
+ 'model/embedding/test',
88
+ 'model/embedding/select',
89
+ 'model/embedding/delete',
90
+ 'model/profiles',
91
+ 'model/profiles/delete',
92
+ 'model/profiles/delete-preview',
93
+ 'model/routes',
94
+ 'model/host-models',
95
+ 'model/host-profiles',
96
+ 'research/preferences',
62
97
  ]);
63
- const WRITABLE_PATH_RE = /^(?:tasks\/[A-Za-z0-9_-]+\/(?:cancel|retry|confirm)|revisions\/(?:selection|from-review|rev_[A-Za-z0-9_-]+\/(?:apply|reject|regenerate)))$/;
64
98
  /** Mutation/SSE state bucket key for context-less (legacy) mutations. */
65
99
  const LEGACY_ROOT_KEY = '';
66
100
  function sendJson(res, status, payload) {
@@ -72,13 +106,33 @@ function sendJson(res, status, payload) {
72
106
  });
73
107
  res.end(body);
74
108
  }
109
+ /**
110
+ * Ceiling for one proxied request body. The runtime route already caps its
111
+ * uploads at 1 KiB; without a cap here an authenticated browser session can
112
+ * push an unbounded body into the host process's memory.
113
+ */
114
+ const MAX_PROXY_BODY_BYTES = 32 * 1024 * 1024;
115
+ /** Ceiling for one proxied download (export / project archive). */
116
+ const MAX_PROXY_DOWNLOAD_BYTES = 64 * 1024 * 1024;
117
+ /** Per-stream ceiling on undelivered SSE bytes before the stream is dropped. */
118
+ const MAX_SSE_PENDING_BYTES = 4 * 1024 * 1024;
119
+ /** Concurrent invalidation streams allowed per workspace root. */
120
+ const MAX_SSE_STREAMS_PER_ROOT = 64;
121
+ /** Reap an SSE connection that has been silent this long (half-open peers). */
122
+ const SSE_IDLE_TIMEOUT_MS = 120_000;
75
123
  async function readBody(req) {
76
124
  const iterator = req[Symbol.asyncIterator];
77
125
  if (iterator === undefined)
78
126
  return Buffer.alloc(0);
79
127
  const chunks = [];
128
+ let total = 0;
80
129
  for await (const chunk of iterator.call(req)) {
81
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
130
+ const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
131
+ total += bytes.byteLength;
132
+ if (total > MAX_PROXY_BODY_BYTES) {
133
+ throw new StudioError(`Proxied request body exceeds ${MAX_PROXY_BODY_BYTES} bytes`, 413, 'PAYLOAD_TOO_LARGE');
134
+ }
135
+ chunks.push(bytes);
82
136
  }
83
137
  return Buffer.concat(chunks);
84
138
  }
@@ -214,6 +268,8 @@ export class NovelDomainService extends Service {
214
268
  connect;
215
269
  baseUrl;
216
270
  resolveWorkspace;
271
+ /** Undelivered SSE bytes per stream, for backpressure (see notifyMutation). */
272
+ streamPending = new Map();
217
273
  /** Per-root invalidation state, keyed by canonical workspace root ('' = legacy). */
218
274
  roots = new Map();
219
275
  /** Open SSE connections bound to the root key they subscribed to. */
@@ -247,9 +303,41 @@ export class NovelDomainService extends Service {
247
303
  state.lastMutation = { revision: state.revision, resource: resourceForPath(path), path };
248
304
  this.roots.set(root, state);
249
305
  const event = JSON.stringify({ workspace_root: root, ...state.lastMutation });
306
+ const payload = `event: invalidate\ndata: ${event}\n\n`;
250
307
  for (const [stream, key] of this.streams) {
251
- if (key === root)
252
- stream.write(`event: invalidate\ndata: ${event}\n\n`);
308
+ if (key !== root)
309
+ continue;
310
+ // Honour backpressure. `write()` returning false means the socket is not
311
+ // draining; without a `drain` listener the host buffers every event for a
312
+ // stalled client without bound. Track pending bytes and drop a stream that
313
+ // falls too far behind — EventSource reconnects and catches up from the
314
+ // revision in the ready event.
315
+ const pending = (this.streamPending.get(stream) ?? 0) + Buffer.byteLength(payload);
316
+ if (pending > MAX_SSE_PENDING_BYTES) {
317
+ this.streamPending.delete(stream);
318
+ this.streams.delete(stream);
319
+ try {
320
+ stream.end();
321
+ }
322
+ catch { /* already disconnected */ }
323
+ continue;
324
+ }
325
+ const drained = stream.write(payload);
326
+ if (drained === false) {
327
+ this.streamPending.set(stream, pending);
328
+ const onDrain = () => {
329
+ stream.on?.('drain', onDrain);
330
+ const left = Math.max(0, (this.streamPending.get(stream) ?? 0) - Buffer.byteLength(payload));
331
+ if (left === 0)
332
+ this.streamPending.delete(stream);
333
+ else
334
+ this.streamPending.set(stream, left);
335
+ };
336
+ stream.on?.('drain', onDrain);
337
+ }
338
+ else {
339
+ this.streamPending.delete(stream);
340
+ }
253
341
  }
254
342
  }
255
343
  /** Snapshot of one root's invalidation state (zero revision when never mutated). */
@@ -283,6 +371,21 @@ export class NovelDomainService extends Service {
283
371
  * closed per the contract: missing id → 400 WORKSPACE_CONTEXT_MISSING, no
284
372
  * registry → 503 WORKSPACE_REGISTRY_UNAVAILABLE, unknown id → 400 WORKSPACE_UNKNOWN.
285
373
  */
374
+ /**
375
+ * The workspace a browser view belongs to, for invalidation only.
376
+ *
377
+ * An unknown or absent workspace id resolves to the global bucket rather
378
+ * than to a rejection: the payload of a global path does not depend on the
379
+ * workspace, so a stale id must not turn a readable answer into an error.
380
+ */
381
+ viewerRoot(id) {
382
+ if (id === undefined || id === '')
383
+ return LEGACY_ROOT_KEY;
384
+ if (this.resolveWorkspace === undefined)
385
+ return LEGACY_ROOT_KEY;
386
+ const root = this.resolveWorkspace(id);
387
+ return root === undefined || root === null ? LEGACY_ROOT_KEY : root;
388
+ }
286
389
  resolveBrowserWorkspace(id) {
287
390
  if (id === undefined) {
288
391
  return { rejection: { status: 400, code: 'WORKSPACE_CONTEXT_MISSING', message: 'missing workspace id (x-dsh-workspace-id header or ?workspace= param)' } };
@@ -315,6 +418,8 @@ export class NovelDomainService extends Service {
315
418
  // route does not close requests already handled by it; end them explicitly
316
419
  // so EventSource reconnects to the newly mounted plugin after a reload.
317
420
  const responses = new Set();
421
+ /** Responses the dispose cleanup already answered; handlers must not write again. */
422
+ const finalized = new Set();
318
423
  const lifetime = new AbortController();
319
424
  let disposed = false;
320
425
  ctx.effect(() => () => {
@@ -322,24 +427,59 @@ export class NovelDomainService extends Service {
322
427
  lifetime.abort();
323
428
  for (const response of responses) {
324
429
  this.streams.delete(response);
430
+ this.streamPending.delete(response);
431
+ finalized.add(response);
432
+ // A response the handler has not started must not be ended with no
433
+ // status and no body: Node silently drops the later writeHead/end, so
434
+ // the browser sees an empty 200 and pollInvalidation throws on .json(),
435
+ // flipping the panel to "offline" on a mere plugin reload.
325
436
  try {
326
- response.end();
437
+ if (response.headersSent !== true) {
438
+ response.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-cache' });
439
+ response.end(JSON.stringify({ error: 'novel-domain is reloading', code: 'NOVEL_DOMAIN_DISPOSED' }));
440
+ }
441
+ else if (response.writableEnded !== true) {
442
+ response.end();
443
+ }
327
444
  }
328
445
  catch { /* already disconnected */ }
329
446
  }
330
447
  responses.clear();
448
+ // Do NOT clear `finalized`: a handler suspended on its upstream epoch read
449
+ // resumes after this cleanup and must still see that its response was
450
+ // already answered, or it writes a second status into a closed socket.
331
451
  }, 'novel-domain: close live invalidation responses');
332
452
  ctx.effect(() => register({
333
453
  kind: 'exact',
334
454
  path: CONFIG_ROUTE,
335
- handler: (req, res) => {
455
+ handler: async (req, res) => {
336
456
  if (req.method !== 'GET' && req.method !== 'HEAD') {
337
457
  res.writeHead(405);
338
458
  res.end();
339
459
  return;
340
460
  }
341
461
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-cache' });
342
- res.end(JSON.stringify({ studioUrl: this.baseUrl }));
462
+ // In managed mode `this.baseUrl` is only the configured default (often
463
+ // the never-bound 127.0.0.1:4567); the real backend is the one `connect()`
464
+ // resolves. Report the live URL when there is one, and say so when the
465
+ // mode makes a static URL meaningless.
466
+ const managed = this.connect !== undefined;
467
+ // Await the resolution so the route answers once, with the real URL.
468
+ let studioUrl = this.baseUrl;
469
+ let available = true;
470
+ if (this.connect !== undefined) {
471
+ try {
472
+ const connection = await this.connect();
473
+ if (connection !== undefined)
474
+ studioUrl = connection.baseUrl;
475
+ }
476
+ catch {
477
+ available = false;
478
+ }
479
+ }
480
+ res.end(JSON.stringify(available
481
+ ? { studioUrl, managed }
482
+ : { studioUrl, managed, available: false }));
343
483
  },
344
484
  }), 'novel-domain: config route');
345
485
  ctx.effect(() => register({
@@ -370,19 +510,35 @@ export class NovelDomainService extends Service {
370
510
  // how polling clients observe background task transitions that never
371
511
  // touch the bridge-local revision.
372
512
  responses.add(res);
373
- const contextEpoch = await this.fetchContextEpoch(resolved.root, resolved.id, lifetime.signal);
374
- responses.delete(res);
375
- if (disposed)
376
- return;
377
- sendJson(res, 200, contextEpoch === null ? snapshot : { ...snapshot, context_epoch: contextEpoch });
513
+ try {
514
+ const contextEpoch = await this.fetchContextEpoch(resolved.root, resolved.id, lifetime.signal);
515
+ // Check `disposed` while still registered: deleting `res` first made
516
+ // this guard unreachable, and the cleanup then ended an unstarted
517
+ // response as an empty 200.
518
+ if (disposed || finalized.has(res)) {
519
+ // Already answered by the dispose cleanup: its 503 notice stands.
520
+ if (!finalized.has(res)) {
521
+ sendJson(res, 503, { error: 'novel-domain is reloading', code: 'NOVEL_DOMAIN_DISPOSED' });
522
+ }
523
+ return;
524
+ }
525
+ sendJson(res, 200, contextEpoch === null ? snapshot : { ...snapshot, context_epoch: contextEpoch });
526
+ }
527
+ finally {
528
+ responses.delete(res);
529
+ }
378
530
  },
379
531
  }), 'novel-domain: invalidation snapshot');
380
532
  ctx.effect(() => register({
381
533
  kind: 'exact',
382
534
  path: EVENTS_ROUTE,
383
535
  handler: async (req, res) => {
384
- if (disposed) {
385
- sendJson(res, 503, { error: 'novel-domain is reloading', code: 'NOVEL_DOMAIN_DISPOSED' });
536
+ if (disposed || finalized.has(res)) {
537
+ // A reload ends every live response with a 503 notice; a handler that
538
+ // resumes afterwards must not write to it again.
539
+ if (!finalized.has(res)) {
540
+ sendJson(res, 503, { error: 'novel-domain is reloading', code: 'NOVEL_DOMAIN_DISPOSED' });
541
+ }
386
542
  return;
387
543
  }
388
544
  if (req.method !== 'GET') {
@@ -395,6 +551,17 @@ export class NovelDomainService extends Service {
395
551
  NovelDomainService.sendRejection(res, resolved.rejection);
396
552
  return;
397
553
  }
554
+ // Cap concurrent streams per root, and reap connections whose peer
555
+ // vanished without FIN/RST (sleep, Wi-Fi handoff) — those never emit
556
+ // 'close', so both maps and the socket leak until OS keepalive.
557
+ let live = 0;
558
+ for (const key of this.streams.values())
559
+ if (key === resolved.root)
560
+ live += 1;
561
+ if (live >= MAX_SSE_STREAMS_PER_ROOT) {
562
+ sendJson(res, 429, { error: 'Too many live invalidation streams for this workspace', code: 'SSE_LIMIT' });
563
+ return;
564
+ }
398
565
  res.writeHead(200, {
399
566
  'content-type': 'text/event-stream',
400
567
  'cache-control': 'no-cache',
@@ -402,16 +569,23 @@ export class NovelDomainService extends Service {
402
569
  });
403
570
  responses.add(res);
404
571
  this.streams.set(res, resolved.root);
405
- const dispose = () => { this.streams.delete(res); responses.delete(res); };
572
+ const dispose = () => { this.streams.delete(res); this.streamPending.delete(res); responses.delete(res); };
406
573
  res.on?.('close', dispose);
407
574
  res.on?.('error', dispose);
575
+ res.setTimeout?.(SSE_IDLE_TIMEOUT_MS, () => { dispose(); try {
576
+ res.end();
577
+ }
578
+ catch { /* already gone */ } });
408
579
  // The ready event carries the upstream context epoch when it is
409
580
  // readable; the stream waits for the fetch to settle (capped at
410
581
  // CONTEXT_EPOCH_TIMEOUT_MS), never longer. A client gone by then
411
582
  // simply never gets the ready event — polling carries epochs anyway.
412
583
  const revision = this.roots.get(resolved.root)?.revision ?? 0;
413
584
  const contextEpoch = await this.fetchContextEpoch(resolved.root, resolved.id, lifetime.signal);
414
- if (disposed || !this.streams.has(res))
585
+ // The dispose cleanup may have ended this response already (with its
586
+ // 503 notice); writing again would throw or be silently dropped. The
587
+ // entry guard above already covers `writableEnded`.
588
+ if (disposed || !this.streams.has(res) || finalized.has(res))
415
589
  return;
416
590
  const ready = { workspace_root: resolved.root, revision };
417
591
  if (contextEpoch !== null)
@@ -433,14 +607,28 @@ export class NovelDomainService extends Service {
433
607
  }
434
608
  // Every browser request carries only a workspace_id; the trusted host
435
609
  // registry resolves it to the canonical root (contract §2.2).
436
- const resolved = this.resolveBrowserWorkspace(headerValue(req, 'x-dsh-workspace-id'));
610
+ const method = req.method ?? 'GET';
611
+ const pathPart = sub.split('?')[0]?.slice(1) ?? '';
612
+ const isWrite = method === 'POST' || method === 'PUT';
613
+ // A global path (the model library) is answered without a workspace:
614
+ // its payload lives outside every project, and the settings dialog has
615
+ // no workspace bound to send.
616
+ const isGlobal = GLOBAL_PATHS.has(pathPart);
617
+ const resolved = isGlobal
618
+ ? { root: '', id: '' }
619
+ : this.resolveBrowserWorkspace(headerValue(req, 'x-dsh-workspace-id'));
437
620
  if ('rejection' in resolved) {
438
621
  NovelDomainService.sendRejection(res, resolved.rejection);
439
622
  return;
440
623
  }
441
- const method = req.method ?? 'GET';
442
- const pathPart = sub.split('?')[0]?.slice(1) ?? '';
443
- const isWrite = method === 'POST' || method === 'PUT';
624
+ // A global path is answered without a workspace: its payload lives
625
+ // outside every project. The view that asked is still a view, though —
626
+ // its own workspace has to learn that the model library moved. The
627
+ // upstream call stays workspace-free; only the invalidation follows the
628
+ // requester, and a request that named no workspace stays global.
629
+ const mutationRoot = isGlobal
630
+ ? this.viewerRoot(headerValue(req, 'x-dsh-workspace-id'))
631
+ : resolved.root;
444
632
  if (!isWrite && method !== 'GET' && method !== 'HEAD') {
445
633
  sendJson(res, 405, { error: 'novel-domain proxy allows only GET/HEAD and allowlisted writes' });
446
634
  return;
@@ -453,8 +641,10 @@ export class NovelDomainService extends Service {
453
641
  try {
454
642
  const headers = {
455
643
  accept: 'application/json',
456
- ...workspaceRootHeaders(resolved.root, true),
457
- 'x-openwrite-workspace-id': resolved.id,
644
+ // A global path carries no workspace identity at all: the Core reads
645
+ // a blank root header as "legacy mode" and serves the request from
646
+ // the root app, whose model library is the user's global one.
647
+ ...(isGlobal ? {} : { ...workspaceRootHeaders(resolved.root, true), 'x-openwrite-workspace-id': resolved.id }),
458
648
  };
459
649
  const sessionId = headerValue(req, 'x-dsh-session-id');
460
650
  if (sessionId !== undefined)
@@ -477,12 +667,27 @@ export class NovelDomainService extends Service {
477
667
  if (lifetime.aborted)
478
668
  return;
479
669
  const actualRoot = upstream.headers.get('X-OpenWrite-Project-Path');
480
- if (upstream.ok && actualRoot && !workspaceRootsEqual(decodeURIComponent(actualRoot), resolved.root)) {
670
+ // A global path asked for no project, so the root the upstream reports
671
+ // is the only one it can have: comparing it against an empty request
672
+ // root would reject every answer.
673
+ if (!isGlobal && upstream.ok && actualRoot && !workspaceRootsEqual(decodeURIComponent(actualRoot), resolved.root)) {
481
674
  await upstream.body?.cancel();
482
675
  sendJson(res, 409, { error: 'Connected workspace differs from requested project', code: 'WORKSPACE_CONTEXT_MISMATCH', details: { requested_path: resolved.root, project_path: decodeURIComponent(actualRoot) } });
483
676
  return;
484
677
  }
678
+ // Bound the buffer: an export or project archive is read fully into
679
+ // memory, and `content-length` is computed from it afterwards.
680
+ const declaredLength = Number(upstream.headers.get('content-length') ?? '');
681
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_PROXY_DOWNLOAD_BYTES) {
682
+ await upstream.body?.cancel();
683
+ sendJson(res, 502, { error: 'The download is too large to proxy', code: 'DOWNLOAD_TOO_LARGE', details: { limit: MAX_PROXY_DOWNLOAD_BYTES } });
684
+ return;
685
+ }
485
686
  const upstreamBytes = Buffer.from(await upstream.arrayBuffer());
687
+ if (upstreamBytes.byteLength > MAX_PROXY_DOWNLOAD_BYTES) {
688
+ sendJson(res, 502, { error: 'The download is too large to proxy', code: 'DOWNLOAD_TOO_LARGE', details: { limit: MAX_PROXY_DOWNLOAD_BYTES } });
689
+ return;
690
+ }
486
691
  const bytes = compactTaskList(upstreamBytes, pathPart, method);
487
692
  const responseHeaders = {
488
693
  'content-type': upstream.headers.get('content-type') ?? 'application/json',
@@ -500,17 +705,33 @@ export class NovelDomainService extends Service {
500
705
  res.writeHead(upstream.status, responseHeaders);
501
706
  res.end(bytes);
502
707
  if (isWrite && upstream.ok && mutationAffectsState(pathPart, body, upstream.headers)) {
503
- this.notifyMutation(`/api/${pathPart}`, { workspaceRoot: resolved.root, workspaceId: resolved.id });
708
+ this.notifyMutation(`/api/${pathPart}`, { workspaceRoot: mutationRoot, workspaceId: resolved.id });
504
709
  }
505
710
  }
506
711
  catch (error) {
507
- if (!lifetime.aborted) {
712
+ // A browser that disconnects mid-upload makes readBody reject; writing a
713
+ // response into a dead socket throws again and masks the real cause.
714
+ if (!lifetime.aborted && !res.headersSent && res.writableEnded !== true) {
508
715
  const failure = error;
509
716
  if (failure.code === 'RUNTIME_PLATFORM_UNSUPPORTED') {
510
717
  sendJson(res, 503, { error: failure.message, code: failure.code });
511
718
  }
512
- else
513
- sendJson(res, 502, { error: '写作环境暂不可用,请打开环境与诊断重试' });
719
+ else if (failure.code === 'PAYLOAD_TOO_LARGE') {
720
+ sendJson(res, 413, { error: failure.message, code: failure.code });
721
+ }
722
+ else if (failure.code === 'DOWNLOAD_TOO_LARGE') {
723
+ sendJson(res, 502, { error: failure.message, code: failure.code });
724
+ }
725
+ else {
726
+ // Preserve the upstream identity: the panel maps specific codes
727
+ // (DOCUMENT_CONFLICT, STALE_REVISION, WORKSPACE_NOT_INITIALIZED, …)
728
+ // to localized, actionable messages. Collapsing them into one 502
729
+ // turns a recoverable conflict into an opaque failure.
730
+ const status = typeof failure.status === 'number' && failure.status >= 400 && failure.status < 600 ? failure.status : 502;
731
+ const code = typeof failure.code === 'string' && failure.code !== '' ? failure.code : 'STUDIO_UNAVAILABLE';
732
+ const message = status === 502 ? '写作环境暂不可用,请打开环境与诊断重试' : (failure.message ?? 'Studio request failed');
733
+ sendJson(res, status, { error: message, code, ...(failure.details === undefined ? {} : { details: failure.details }) });
734
+ }
514
735
  }
515
736
  }
516
737
  finally {
@@ -0,0 +1,188 @@
1
+ /**
2
+ * A loopback-only, OpenAI-compatible relay onto the host's own model
3
+ * directory.
4
+ *
5
+ * OpenWrite's Core speaks the OpenAI chat-completions protocol (LiteLLM), so
6
+ * giving it a plain HTTP endpoint is enough to make every model dsh itself can
7
+ * reach — the host's own adapters and the models other plugins register into
8
+ * the same directory — usable from a writing task. The relay never sees a
9
+ * provider key: the host's `llm` service owns the credentials and performs the
10
+ * call, so the relay only translates a request shape.
11
+ *
12
+ * The relay is deliberately NOT mounted on the host's web server. That port is
13
+ * browser-authenticated and possibly reachable from the LAN; this one is bound
14
+ * to 127.0.0.1 on an ephemeral port and answers only to a machine token that
15
+ * is handed to the Core process over its private stdio handshake.
16
+ */
17
+ /** Structural view of the host's `llm` service (`ctx.get('llm')`). */
18
+ export interface HostLlmService {
19
+ listProviders(): Array<{
20
+ id: string;
21
+ name: string;
22
+ }>;
23
+ listConfigurableProviders?(): Array<{
24
+ provider: string;
25
+ displayName: string;
26
+ settingsNs: string;
27
+ }>;
28
+ listModels(provider: string): Promise<Array<{
29
+ provider: string;
30
+ id: string;
31
+ name: string;
32
+ description?: string;
33
+ }>>;
34
+ resolveModelInfo?(provider: string, model: string, signal?: AbortSignal): Promise<{
35
+ context?: {
36
+ contextWindow?: number;
37
+ };
38
+ }>;
39
+ stream(options: HostGenerateOptions): AsyncIterable<HostStreamChunk>;
40
+ }
41
+ /** Why the host's model call stopped. A failure carries the provider's words. */
42
+ export interface HostFinishReason {
43
+ kind: 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error' | string;
44
+ failure?: {
45
+ message?: string;
46
+ code?: string;
47
+ status?: number;
48
+ };
49
+ }
50
+ export interface HostGenerateOptions {
51
+ provider: string;
52
+ model: string;
53
+ messages: unknown[];
54
+ temperature?: number;
55
+ maxTokens?: number;
56
+ stop?: string[];
57
+ tools?: unknown[];
58
+ reasoningEffort?: string;
59
+ signal?: AbortSignal;
60
+ }
61
+ export type HostStreamChunk = {
62
+ type: 'block-start';
63
+ index: number;
64
+ blockType: string;
65
+ } | {
66
+ type: 'text-delta';
67
+ index: number;
68
+ text: string;
69
+ } | {
70
+ type: 'reasoning-delta';
71
+ index: number;
72
+ text: string;
73
+ } | {
74
+ type: 'tool-call-delta';
75
+ index: number;
76
+ id: string;
77
+ name?: string;
78
+ argumentsDelta: string;
79
+ } | {
80
+ type: 'block-end';
81
+ index: number;
82
+ block: {
83
+ type: string;
84
+ text?: string;
85
+ id?: string;
86
+ name?: string;
87
+ arguments?: string;
88
+ };
89
+ } | {
90
+ type: 'usage';
91
+ usage: {
92
+ inputTokens?: number;
93
+ outputTokens?: number;
94
+ totalTokens?: number;
95
+ };
96
+ } | {
97
+ type: 'finish';
98
+ reason: string | HostFinishReason;
99
+ };
100
+ /** The host's own default-model selection, when the host composes that service. */
101
+ export type HostDefaultSelection = () => {
102
+ provider: string;
103
+ model: string;
104
+ reasoningEffort?: string;
105
+ } | undefined;
106
+ /** Pseudo model id meaning "whatever dsh itself would use right now". */
107
+ export declare const HOST_CURRENT_MODEL = "host/current";
108
+ /** One directory entry the Core side renders as a selectable model. */
109
+ export interface HostModelEntry {
110
+ /** `provider/model` route, or {@link HOST_CURRENT_MODEL}. */
111
+ id: string;
112
+ provider: string;
113
+ model: string;
114
+ label: string;
115
+ description: string;
116
+ /** The route's own context budget, when the adapter declares one. */
117
+ contextTokens: number;
118
+ }
119
+ export interface HostModelCatalog {
120
+ available: boolean;
121
+ providers: Array<{
122
+ id: string;
123
+ name: string;
124
+ }>;
125
+ models: HostModelEntry[];
126
+ /** Present when the host composes a default-model service. */
127
+ current: {
128
+ provider: string;
129
+ model: string;
130
+ } | null;
131
+ error: string;
132
+ }
133
+ export interface HostLlmRelayOptions {
134
+ llm: HostLlmService;
135
+ /** Machine token; every request must present it as a bearer credential. */
136
+ token: string;
137
+ /** The host's default-model selection, when the host composes that service. */
138
+ defaultSelection?: HostDefaultSelection;
139
+ /**
140
+ * How long one provider's directory may take before the catalog gives up on
141
+ * it. Overridable so the gate can be exercised without waiting for it.
142
+ */
143
+ providerDeadlineMs?: number;
144
+ /**
145
+ * How long one model's context lookup may take. It is optional metadata, so
146
+ * a slow lookup must cost the entry its context window, never the entry.
147
+ */
148
+ modelDeadlineMs?: number;
149
+ }
150
+ export declare class HostLlmRelay {
151
+ private readonly options;
152
+ private server?;
153
+ private port;
154
+ private closed;
155
+ constructor(options: HostLlmRelayOptions);
156
+ /** Bind the loopback port. Idempotent. */
157
+ listen(): Promise<void>;
158
+ /** The OpenAI-compatible base URL Core should use. */
159
+ baseUrl(): string;
160
+ /** The machine token Core must present. */
161
+ token(): string;
162
+ close(): Promise<void>;
163
+ /** The host's model directory, including the models other plugins registered. */
164
+ catalog(): Promise<HostModelCatalog>;
165
+ /** One provider's models, or none when that provider did not answer in time. */
166
+ private providerModels;
167
+ /** The route's own context budget, or 0 when the adapter declares none. */
168
+ private contextTokens;
169
+ /** Split `provider/model`; the first segment is the provider route. */
170
+ private resolveRoute;
171
+ private handle;
172
+ private authorized;
173
+ private chatCompletions;
174
+ /**
175
+ * Retry once without the reasoning effort when the host refuses it.
176
+ *
177
+ * A model that declares no reasoning support rejects an explicit effort
178
+ * (the host reports `UNSUPPORTED_REASONING_EFFORT` as a terminal finish).
179
+ * Asking for "no reasoning" must never turn into a failed call, so the
180
+ * second attempt drops the effort and lets the provider decide. The retry
181
+ * only happens before any content was produced: the refusal arrives as the
182
+ * first chunk, so nothing has been written to the client yet.
183
+ */
184
+ private withEffortFallback;
185
+ private writeChunk;
186
+ }
187
+ /** A fresh machine token for one relay instance. */
188
+ export declare function newRelayToken(): string;