@edspencer/paddock 0.72.1 → 0.74.0

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 (93) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/config.d.ts +15 -0
  3. package/packages/server/dist/config.js +19 -0
  4. package/packages/server/dist/instance-config.js +5 -1
  5. package/packages/server/dist/models.d.ts +26 -6
  6. package/packages/server/dist/models.js +45 -10
  7. package/packages/server/dist/project-files.d.ts +5 -4
  8. package/packages/server/dist/project-files.js +6 -5
  9. package/packages/server/dist/project-mime.d.ts +5 -5
  10. package/packages/server/dist/project-mime.js +7 -5
  11. package/packages/server/dist/project-paths.d.ts +15 -0
  12. package/packages/server/dist/project-paths.js +15 -0
  13. package/packages/server/dist/project-types.d.ts +1 -1
  14. package/packages/server/dist/projects.d.ts +2 -2
  15. package/packages/server/dist/projects.js +43 -7
  16. package/packages/server/dist/routes/chats.js +24 -2
  17. package/packages/server/dist/routes/git.js +4 -1
  18. package/packages/server/dist/routes/meta.js +5 -1
  19. package/packages/server/dist/routes/projects.js +5 -2
  20. package/packages/server/dist/ui-config.d.ts +43 -0
  21. package/packages/server/dist/ui-config.js +28 -0
  22. package/packages/server/package.json +1 -1
  23. package/packages/web/dist/assets/DiscoverPage-BX2Q0ycN.js +2 -0
  24. package/packages/web/dist/assets/DiscoverView-BPH1QcJ9.js +2 -0
  25. package/packages/web/dist/assets/{InstanceConfigPage-aOgiBFLq.js → InstanceConfigPage-C0p7EP0D.js} +1 -1
  26. package/packages/web/dist/assets/{MarkdownRenderer-BjrB0TDO.js → MarkdownRenderer-DpATMqaW.js} +1 -1
  27. package/packages/web/dist/assets/ProjectMenu-COBWHez-.js +2 -0
  28. package/packages/web/dist/assets/{ProjectRedirect-N4g7WrZm.js → ProjectRedirect-DsDlOmgR.js} +1 -1
  29. package/packages/web/dist/assets/ProjectView-Da-HMPaX.js +13 -0
  30. package/packages/web/dist/assets/ProjectsGrid-B7Tu8bJa.js +2 -0
  31. package/packages/web/dist/assets/{RootHome-C9wEtWHu.js → RootHome-BR2M_FNm.js} +1 -1
  32. package/packages/web/dist/assets/{Surfaces-BidCkENw.js → Surfaces-DKrmOzkk.js} +1 -1
  33. package/packages/web/dist/assets/{arc-BctfAqLY.js → arc-BUDm_v94.js} +1 -1
  34. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BJgwSqOO.js → architectureDiagram-3BPJPVTR-D4ZBf5MA.js} +1 -1
  35. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BD2skmST.js → blockDiagram-GPEHLZMM-VTYIZW4q.js} +1 -1
  36. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DckMrhMv.js → c4Diagram-AAUBKEIU-CyUzrDQ0.js} +1 -1
  37. package/packages/web/dist/assets/channel-BEbx-lLm.js +2 -0
  38. package/packages/web/dist/assets/{chunk-2J33WTMH-CQDUH7rp.js → chunk-2J33WTMH-BtndMYUq.js} +1 -1
  39. package/packages/web/dist/assets/{chunk-4BX2VUAB-Bae1J961.js → chunk-4BX2VUAB-sN2R3uRX.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-55IACEB6-CXxS5wOa.js → chunk-55IACEB6-Dft9UrXF.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-727SXJPM-DSNFkPB3.js → chunk-727SXJPM-B9VXcd9S.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-AQP2D5EJ-B2mqFyB7.js → chunk-AQP2D5EJ-D3HMB7td.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-FMBD7UC4-BfqEy3lu.js → chunk-FMBD7UC4-B50IDAXn.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-ND2GUHAM-Bm6CBVK7.js → chunk-ND2GUHAM-BthbFTUl.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-QZHKN3VN-NmnLtgYc.js → chunk-QZHKN3VN-ZArPIBZ4.js} +1 -1
  46. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuHRhAai.js +2 -0
  47. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuHRhAai.js +2 -0
  48. package/packages/web/dist/assets/{core-YKeFSK1q.js → core-DfGQq4F4.js} +1 -1
  49. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-qf_mXpa9.js → cose-bilkent-S5V4N54A-o6695fVh.js} +1 -1
  50. package/packages/web/dist/assets/{dagre-BM42HDAG-C31v-j3s.js → dagre-BM42HDAG-BbJtmARn.js} +1 -1
  51. package/packages/web/dist/assets/{diagram-2AECGRRQ-C_TiPHdS.js → diagram-2AECGRRQ-gMpt4UAN.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-5GNKFQAL-BC8gUFK-.js → diagram-5GNKFQAL-6Yf0abcV.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-KO2AKTUF-vO_QYWxi.js → diagram-KO2AKTUF-Cbux4Jaa.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-LMA3HP47-vxJ-YZOg.js → diagram-LMA3HP47-B4Pz9qdB.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-OG6HWLK6-BNIfJG6l.js → diagram-OG6HWLK6-DUwGWY8v.js} +1 -1
  56. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-B37HsOFM.js → erDiagram-TEJ5UH35-BYe6FsbI.js} +1 -1
  57. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DqA5X81H.js → flowDiagram-I6XJVG4X-CBjmjKdl.js} +1 -1
  58. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BMP0zpIg.js → ganttDiagram-6RSMTGT7-a_XquFE3.js} +1 -1
  59. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-P0nXaCzx.js → gitGraphDiagram-PVQCEYII-eTvZb_j2.js} +1 -1
  60. package/packages/web/dist/assets/index-Bmrlxsb0.css +1 -0
  61. package/packages/web/dist/assets/index-CIGlBQAs.js +71 -0
  62. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-DpKYQ7VK.js → infoDiagram-5YYISTIA-D9KnLZ9C.js} +1 -1
  63. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BZeRMg3C.js → ishikawaDiagram-YF4QCWOH-CYfdAUXb.js} +1 -1
  64. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Cg2O0qzv.js → journeyDiagram-JHISSGLW-BS5b0yBH.js} +1 -1
  65. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DBwVxUvI.js → kanban-definition-UN3LZRKU-DNGUO5mT.js} +1 -1
  66. package/packages/web/dist/assets/{linear-R8qRwYqx.js → linear-DxEiALSJ.js} +1 -1
  67. package/packages/web/dist/assets/{mermaid.core-jctTs2en.js → mermaid.core-CrttYAaz.js} +4 -4
  68. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CtmsNfpb.js → mindmap-definition-RKZ34NQL-B5pnbSAD.js} +1 -1
  69. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-C-UiLFDv.js → pieDiagram-4H26LBE5-GGhWYy-7.js} +1 -1
  70. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BS4SWa01.js → quadrantDiagram-W4KKPZXB-SErMkbVi.js} +1 -1
  71. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-LPPAC6Q1.js → requirementDiagram-4Y6WPE33-JKwTIXzH.js} +1 -1
  72. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-tjF8KyS3.js → sankeyDiagram-5OEKKPKP-DOAQnux6.js} +1 -1
  73. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-kmddkdk2.js → sequenceDiagram-3UESZ5HK-m6O3XSti.js} +1 -1
  74. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-B07mykTY.js → stateDiagram-AJRCARHV-kuTP2Tq-.js} +1 -1
  75. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CQizvmc0.js +2 -0
  76. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DrUgi0cT.js → timeline-definition-PNZ67QCA-BsUvko-L.js} +1 -1
  77. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dz6p0m7l.js → vennDiagram-CIIHVFJN-BbzLQnXq.js} +1 -1
  78. package/packages/web/dist/assets/{wardley-L42UT6IY-DM16lA5x.js → wardley-L42UT6IY-DZ9zhTE4.js} +1 -1
  79. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CCFQkFGB.js → wardleyDiagram-YWT4CUSO-DEtCkWvy.js} +1 -1
  80. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DiUI5Dos.js → xychartDiagram-2RQKCTM6-CPZHKB8u.js} +1 -1
  81. package/packages/web/dist/index.html +2 -2
  82. package/packages/web/dist/sw.js +1 -1
  83. package/packages/web/dist/assets/DiscoverPage-DhrXvgkl.js +0 -2
  84. package/packages/web/dist/assets/DiscoverView-yPLCp_YE.js +0 -2
  85. package/packages/web/dist/assets/ProjectMenu-PhwXTmgn.js +0 -2
  86. package/packages/web/dist/assets/ProjectView-B-7AIt--.js +0 -13
  87. package/packages/web/dist/assets/ProjectsGrid-DZF5VHJF.js +0 -2
  88. package/packages/web/dist/assets/channel-CsZOz8jm.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DdALkhE9.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DdALkhE9.js +0 -2
  91. package/packages/web/dist/assets/index-BkY_Nc_i.css +0 -1
  92. package/packages/web/dist/assets/index-C3MdluUb.js +0 -71
  93. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-r6WdX0dw.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.72.1",
3
+ "version": "0.74.0",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -10,6 +10,7 @@ import { type RecoveryConfig } from "./recovery-config.js";
10
10
  import { type CurationConfig } from "./curation-config.js";
11
11
  import { type ManagementApiConfig, type ManagementApiConfigFile } from "./management-config.js";
12
12
  import { type AttachmentsConfig } from "./attachments-config.js";
13
+ import { type UiConfig } from "./ui-config.js";
13
14
  /**
14
15
  * User-authentication strategy.
15
16
  *
@@ -433,6 +434,13 @@ export interface PaddockConfig {
433
434
  * allow-all by default.
434
435
  */
435
436
  attachments: AttachmentsConfig;
437
+ /**
438
+ * Instance-level UI knobs (issue #914) — currently just how many trailing
439
+ * messages a chat transcript renders on open. Instance-only by design: there is
440
+ * no per-project override, because this describes the operator's browser
441
+ * tolerance, not anything about a project.
442
+ */
443
+ ui: UiConfig;
436
444
  /**
437
445
  * Per-file token budgets the post-turn sweeper (curation) must keep its three
438
446
  * curated files under (issue #379). These bound the context every keeper chat
@@ -614,6 +622,13 @@ export interface PaddockConfigFile {
614
622
  maxFilesPerMessage?: number | string;
615
623
  allowedTypes?: string[] | string;
616
624
  };
625
+ /**
626
+ * Instance-level UI knobs (issue #914). Every field optional; a matching
627
+ * `PADDOCK_UI_*` env var still overrides it (file < env).
628
+ */
629
+ ui?: {
630
+ transcriptRenderLimit?: number | string;
631
+ };
617
632
  /**
618
633
  * Per-file curation token budgets (issue #379). Every field optional; a
619
634
  * matching `PADDOCK_CURATION_*` env var still overrides it (file < env).
@@ -37,6 +37,7 @@ import { DEFAULT_RECOVERY } from "./recovery-config.js";
37
37
  import { DEFAULT_CURATION } from "./curation-config.js";
38
38
  import { resolveManagementApiConfig, } from "./management-config.js";
39
39
  import { DEFAULT_ATTACHMENTS, sanitizeAllowedTypes, } from "./attachments-config.js";
40
+ import { DEFAULT_UI } from "./ui-config.js";
40
41
  function abs(p) {
41
42
  return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
42
43
  }
@@ -348,6 +349,7 @@ export function loadPaddockConfig(opts = {}) {
348
349
  hooksMcpEnabled: loadHooksMcpEnabled(file.hooksMcpEnabled, p.hooksMcpEnabled),
349
350
  recovery: loadRecoveryConfig(file.recovery),
350
351
  attachments: loadAttachmentsConfig(file.attachments),
352
+ ui: loadUiConfig(file.ui),
351
353
  curation: loadCurationConfig(file.curation),
352
354
  logLevel: envOr("LOG_LEVEL", fileOr(file.logLevel, "info")),
353
355
  browserMcp: loadBrowserMcp(file.browserMcp, p.browserMcp),
@@ -514,6 +516,23 @@ function loadAttachmentsConfig(file) {
514
516
  allowedTypes: loadAllowedTypes(f.allowedTypes, DEFAULT_ATTACHMENTS.allowedTypes),
515
517
  };
516
518
  }
519
+ /**
520
+ * Resolve the instance-level UI config (issue #914).
521
+ *
522
+ * `transcriptRenderLimit` is a NON-NEGATIVE knob — `0` is the meaningful value
523
+ * "unlimited", so this cannot reuse {@link loadAttachmentsInt}, whose floor is 1.
524
+ * A missing/blank/non-integer/negative value falls back to the default rather
525
+ * than failing startup, like every other loader here.
526
+ */
527
+ function loadUiConfig(file) {
528
+ const raw = envOpt("PADDOCK_UI_TRANSCRIPT_RENDER_LIMIT") ?? fileOpt(file?.transcriptRenderLimit);
529
+ if (raw === undefined)
530
+ return { ...DEFAULT_UI };
531
+ const n = Number(raw);
532
+ return {
533
+ transcriptRenderLimit: Number.isInteger(n) && n >= 0 ? n : DEFAULT_UI.transcriptRenderLimit,
534
+ };
535
+ }
517
536
  /**
518
537
  * Resolve the per-deployment schedule-mutation gate (issue #265 / DD-7). Defaults
519
538
  * OFF so a plain instance can't have its schedules mutated programmatically; an
@@ -35,6 +35,7 @@ import { DRIVE_MODES, DEFAULT_DRIVE_MODE, MODELS, isKnownModel } from "./models.
35
35
  import { DEFAULT_MAX_SPAWN_DEPTH, isValidMaxSpawnDepth } from "./spawn-capability.js";
36
36
  import { DEFAULT_RECOVERY } from "./recovery-config.js";
37
37
  import { DEFAULT_ATTACHMENTS, sanitizeAllowedTypes } from "./attachments-config.js";
38
+ import { DEFAULT_UI } from "./ui-config.js";
38
39
  import { DEFAULT_CURATION } from "./curation-config.js";
39
40
  import { DEFAULT_ENVIRONMENT_PROMPT } from "./environment-prompt.js";
40
41
  import { CONFIG_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schema-version.js";
@@ -51,6 +52,7 @@ export const GROUPS = [
51
52
  { id: "capabilities", label: "Capabilities", description: "What keeper agents are allowed to do. Most default off." },
52
53
  { id: "recovery", label: "Recovery" },
53
54
  { id: "attachments", label: "Attachments" },
55
+ { id: "ui", label: "Interface", description: "How the web UI renders. These affect the browser only — nothing is deleted or changed on disk." },
54
56
  { id: "branding", label: "Branding" },
55
57
  { id: "transcription", label: "Transcription" },
56
58
  { id: "git", label: "Git identity" },
@@ -245,7 +247,7 @@ export const FIELDS = [
245
247
  { key: "sweepMinIntervalMs", group: "sweeper", label: "Min sweep interval (ms)", help: "Minimum ms between post-turn sweeps for one project. Blank = default (5 min).", type: "number", envVars: ["PADDOCK_SWEEP_MIN_INTERVAL_MS"], default: null, editable: true, coerce: optNonNegNumber },
246
248
  // Capabilities.
247
249
  { key: "driveMode", group: "capabilities", label: "Keeper drive mode", help: "session = persistent (streaming + cross-turn autonomy); batch = legacy one-shot.", type: "enum", enumValues: DRIVE_MODES, envVars: ["PADDOCK_DRIVE_MODE"], default: DEFAULT_DRIVE_MODE, editable: true, coerce: oneOf(DRIVE_MODES) },
248
- { key: "models", group: "capabilities", label: "Offered models", help: "Which built-in catalog models the picker offers, by id (e.g. claude-opus-5, claude-sonnet-5). Blank = offer all catalog models.", type: "string-list", envVars: ["PADDOCK_MODELS"], default: MODELS.map((m) => m.id), editable: true, coerce: modelList },
250
+ { key: "models", group: "capabilities", label: "Offered models", help: "Which built-in catalog models the picker offers, by id (e.g. claude-opus-5-5, claude-sonnet-5). Blank = offer all catalog models.", type: "string-list", envVars: ["PADDOCK_MODELS"], default: MODELS.map((m) => m.id), editable: true, coerce: modelList },
249
251
  { key: "nativeSystemPrompt", group: "capabilities", label: "Native system prompt", help: "Use Claude Code's native prompt + CLAUDE.md hierarchy (recommended).", type: "boolean", envVars: ["PADDOCK_NATIVE_PROMPT"], default: true, editable: true, coerce: asBool },
250
252
  // Issue #635. `envShadowWhenDefined` because loadEnvironmentPrompt keys on
251
253
  // definedness, not emptiness — a defined-but-empty PADDOCK_ENVIRONMENT_PROMPT
@@ -270,6 +272,8 @@ export const FIELDS = [
270
272
  { key: "attachments.maxFileSizeMb", group: "attachments", label: "Max file size (MB)", type: "number", envVars: ["PADDOCK_ATTACHMENTS_MAX_FILE_SIZE_MB"], default: DEFAULT_ATTACHMENTS.maxFileSizeMb, editable: true, coerce: posInt },
271
273
  { key: "attachments.maxFilesPerMessage", group: "attachments", label: "Max files / message", type: "number", envVars: ["PADDOCK_ATTACHMENTS_MAX_FILES_PER_MESSAGE"], default: DEFAULT_ATTACHMENTS.maxFilesPerMessage, editable: true, coerce: posInt },
272
274
  { key: "attachments.allowedTypes", group: "attachments", label: "Allowed types", help: "MIME types / extensions (e.g. image/*, .pdf). * = allow all.", type: "string-list", envVars: ["PADDOCK_ATTACHMENTS_ALLOWED_TYPES"], default: [...DEFAULT_ATTACHMENTS.allowedTypes], editable: true, coerce: stringList },
275
+ // Interface (issue #914). `nonNegInt`, not `posInt`: 0 means "render everything".
276
+ { key: "ui.transcriptRenderLimit", group: "ui", label: "Transcript render limit", help: "How many recent messages a chat renders on open. 0 = no limit. Older messages stay on disk and are not deleted.", type: "number", envVars: ["PADDOCK_UI_TRANSCRIPT_RENDER_LIMIT"], default: DEFAULT_UI.transcriptRenderLimit, editable: true, coerce: nonNegInt },
273
277
  // Branding (issue #34).
274
278
  { key: "brand.name", group: "branding", label: "Name", type: "string", envVars: ["PADDOCK_BRAND_NAME"], default: "Paddock", editable: true, coerce: nonEmptyString },
275
279
  { key: "brand.logo", group: "branding", label: "Logo", help: "An emoji/glyph, or a URL/path to an image.", type: "string", envVars: ["PADDOCK_BRAND_LOGO"], default: "🐎", editable: true, coerce: nonEmptyString },
@@ -9,10 +9,11 @@
9
9
  * constants — so changing the available models or a context limit is a one-file
10
10
  * edit here.
11
11
  *
12
- * Context limits (verified against the Models API): Opus 5, Fable 5, Opus 4.8
13
- * and Sonnet 5 all have a 1,000,000-token context window; Haiku 4.5 is 200,000.
14
- * On the Max plan the keeper agents run Opus 5 at its full 1M window, so
15
- * the context meter must use 1M for it — otherwise a long chat reads >100%.
12
+ * Context limits (verified against the Models API): Opus 5.5, Opus 5, Fable 5.1,
13
+ * Fable 5, Opus 4.8 and Sonnet 5 all have a 1,000,000-token context window;
14
+ * Haiku 4.5 is 200,000.
15
+ * On the Max plan the keeper agents run Opus at its full 1M window, so the
16
+ * context meter must use 1M for it — otherwise a long chat reads >100%.
16
17
  */
17
18
  /**
18
19
  * Public first-party API list price per 1M tokens, used only to put a
@@ -28,6 +29,16 @@ export interface ModelPricing {
28
29
  inputPer1M: number;
29
30
  /** USD per 1M output tokens. */
30
31
  outputPer1M: number;
32
+ /**
33
+ * Cache-read price as a multiple of {@link inputPer1M}, when the model departs
34
+ * from the standard 0.1× ({@link CACHE_READ_MULTIPLIER}). Two models do:
35
+ * Fable 5.1 (and Mythos 5.1) read cached tokens at **0.025×** ($0.25/MTok
36
+ * against a $10 base), and Opus 5.5 at **0.05×** ($0.20/MTok against a $4
37
+ * base). Every other model is the standard tenth. Cache reads dominate a long
38
+ * chat's token counts, so getting this wrong overstates a Fable 5.1 chat's
39
+ * cost ~4× and an Opus 5.5 chat's ~2×.
40
+ */
41
+ cacheReadMultiplier?: number;
31
42
  }
32
43
  /** A single selectable model: its id, a human label, and its context window. */
33
44
  export interface ModelInfo {
@@ -46,8 +57,17 @@ export interface ModelInfo {
46
57
  * renders.
47
58
  */
48
59
  export declare const MODELS: ModelInfo[];
49
- /** The model a project runs on unless the project overrides it. */
50
- export declare const DEFAULT_MODEL = "claude-opus-5";
60
+ /**
61
+ * The model a project runs on unless the project overrides it. Moved from
62
+ * Opus 5 to Opus 5.5 when 5.5 shipped: it is both newer and cheaper ($4/$20 vs
63
+ * $5/$25, and half-price cache reads), so unpinned projects get a better model
64
+ * for less. A project that pins `model` in its `project.yaml` is untouched.
65
+ *
66
+ * Keep this equal to `MODELS[0].id` — the picker renders catalog order, so the
67
+ * default must be the first entry or the UI implies a different default than
68
+ * the server applies. `models.test.ts` asserts the two agree.
69
+ */
70
+ export declare const DEFAULT_MODEL = "claude-opus-5-5";
51
71
  /**
52
72
  * Resolve the models OFFERED (the picker/allow-list) from an optional list of
53
73
  * allowed ids (issue #457 Step 2). {@link MODELS} stays the authoritative CATALOG
@@ -9,10 +9,11 @@
9
9
  * constants — so changing the available models or a context limit is a one-file
10
10
  * edit here.
11
11
  *
12
- * Context limits (verified against the Models API): Opus 5, Fable 5, Opus 4.8
13
- * and Sonnet 5 all have a 1,000,000-token context window; Haiku 4.5 is 200,000.
14
- * On the Max plan the keeper agents run Opus 5 at its full 1M window, so
15
- * the context meter must use 1M for it — otherwise a long chat reads >100%.
12
+ * Context limits (verified against the Models API): Opus 5.5, Opus 5, Fable 5.1,
13
+ * Fable 5, Opus 4.8 and Sonnet 5 all have a 1,000,000-token context window;
14
+ * Haiku 4.5 is 200,000.
15
+ * On the Max plan the keeper agents run Opus at its full 1M window, so the
16
+ * context meter must use 1M for it — otherwise a long chat reads >100%.
16
17
  */
17
18
  /**
18
19
  * The selectable models, in picker order. The first entry is the keeper default
@@ -20,6 +21,12 @@
20
21
  * renders.
21
22
  */
22
23
  export const MODELS = [
24
+ {
25
+ id: "claude-opus-5-5",
26
+ label: "Opus 5.5",
27
+ contextLimit: 1_000_000,
28
+ pricing: { inputPer1M: 4, outputPer1M: 20, cacheReadMultiplier: 0.05 },
29
+ },
23
30
  {
24
31
  id: "claude-opus-5",
25
32
  label: "Opus 5",
@@ -32,6 +39,12 @@ export const MODELS = [
32
39
  contextLimit: 1_000_000,
33
40
  pricing: { inputPer1M: 5, outputPer1M: 25 },
34
41
  },
42
+ {
43
+ id: "claude-fable-5-1",
44
+ label: "Fable 5.1",
45
+ contextLimit: 1_000_000,
46
+ pricing: { inputPer1M: 10, outputPer1M: 50, cacheReadMultiplier: 0.025 },
47
+ },
35
48
  {
36
49
  id: "claude-fable-5",
37
50
  label: "Fable 5",
@@ -42,7 +55,11 @@ export const MODELS = [
42
55
  id: "claude-sonnet-5",
43
56
  label: "Sonnet 5",
44
57
  contextLimit: 1_000_000,
45
- pricing: { inputPer1M: 3, outputPer1M: 15 },
58
+ // $2/$10, not $3/$15: the $2/$10 launch price was announced as introductory
59
+ // through 2026-08-31, but the scheduled 2026-09-01 rise to $3/$15 was
60
+ // cancelled and $2/$10 is now standard. We shipped the increase that never
61
+ // happened, overstating every Sonnet 5 cost estimate by 50%.
62
+ pricing: { inputPer1M: 2, outputPer1M: 10 },
46
63
  },
47
64
  {
48
65
  id: "claude-haiku-4-5-20251001",
@@ -51,8 +68,17 @@ export const MODELS = [
51
68
  pricing: { inputPer1M: 1, outputPer1M: 5 },
52
69
  },
53
70
  ];
54
- /** The model a project runs on unless the project overrides it. */
55
- export const DEFAULT_MODEL = "claude-opus-5";
71
+ /**
72
+ * The model a project runs on unless the project overrides it. Moved from
73
+ * Opus 5 to Opus 5.5 when 5.5 shipped: it is both newer and cheaper ($4/$20 vs
74
+ * $5/$25, and half-price cache reads), so unpinned projects get a better model
75
+ * for less. A project that pins `model` in its `project.yaml` is untouched.
76
+ *
77
+ * Keep this equal to `MODELS[0].id` — the picker renders catalog order, so the
78
+ * default must be the first entry or the UI implies a different default than
79
+ * the server applies. `models.test.ts` asserts the two agree.
80
+ */
81
+ export const DEFAULT_MODEL = "claude-opus-5-5";
56
82
  /**
57
83
  * Resolve the models OFFERED (the picker/allow-list) from an optional list of
58
84
  * allowed ids (issue #457 Step 2). {@link MODELS} stays the authoritative CATALOG
@@ -142,7 +168,12 @@ export function isValidMaxTurns(n) {
142
168
  }
143
169
  /** The cheap model the post-turn sweeper (curator) always uses. */
144
170
  export const SWEEPER_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
145
- /** Default context limit when a model id is unknown (every current model is 200k). */
171
+ /**
172
+ * Default context limit when a model id is unknown. 200k is the conservative
173
+ * floor, not the common case — every catalog model except Haiku 4.5 is 1M. An
174
+ * unknown id reading 200k makes the context meter over-report rather than
175
+ * silently divide by undefined.
176
+ */
146
177
  const DEFAULT_CONTEXT_LIMIT = 200000;
147
178
  /** Whether `id` is one of the known selectable models. */
148
179
  export function isKnownModel(id) {
@@ -159,7 +190,10 @@ export function getContextLimit(id) {
159
190
  export function getModelInfo(id) {
160
191
  return MODELS.find((m) => m.id === id);
161
192
  }
162
- /** Cache-write (5-minute ephemeral) is 1.25× the input rate; cache-read is 0.1×. */
193
+ /**
194
+ * Cache-write (5-minute ephemeral) is 1.25× the input rate; cache-read is 0.1×
195
+ * — except on models that override it via {@link ModelPricing.cacheReadMultiplier}.
196
+ */
163
197
  const CACHE_WRITE_MULTIPLIER = 1.25;
164
198
  const CACHE_READ_MULTIPLIER = 0.1;
165
199
  /**
@@ -176,9 +210,10 @@ export function estimateCostUsd(modelId, t) {
176
210
  if (!pricing)
177
211
  return null;
178
212
  const { inputPer1M, outputPer1M } = pricing;
213
+ const cacheRead = pricing.cacheReadMultiplier ?? CACHE_READ_MULTIPLIER;
179
214
  const usd = (t.inputTokens * inputPer1M +
180
215
  t.cacheCreationTokens * inputPer1M * CACHE_WRITE_MULTIPLIER +
181
- t.cacheReadTokens * inputPer1M * CACHE_READ_MULTIPLIER +
216
+ t.cacheReadTokens * inputPer1M * cacheRead +
182
217
  t.outputTokens * outputPer1M) /
183
218
  1_000_000;
184
219
  return usd;
@@ -74,10 +74,11 @@ export declare function readFileBytes(dir: string, name: string, hiddenSegments?
74
74
  * UI's markdown/Mermaid + sandboxed-iframe renderers (issue #3) and the image
75
75
  * viewer (issue #61).
76
76
  *
77
- * For an IMAGE the raw bytes are NOT returned here (decoding binary as UTF-8
78
- * would mangle it): `content` is empty and the client fetches the bytes from
79
- * the raw endpoint. We still stat the file so a missing image 404s. Path-
80
- * traversal guarded; throws ProjectError("not_found") when missing. See
77
+ * For a BINARY kind (image, pdf) the raw bytes are NOT returned here (decoding
78
+ * binary as UTF-8 would mangle it — a PDF read this way is the garbled text of
79
+ * issue #917): `content` is empty and the client fetches the bytes from the raw
80
+ * endpoint. We still stat the file so a missing one 404s. Path-traversal
81
+ * guarded; throws ProjectError("not_found") when missing. See
81
82
  * {@link resolveInProject} for `hiddenSegments`.
82
83
  */
83
84
  export declare function readFileWithKind(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): Promise<{
@@ -156,15 +156,16 @@ export async function readFileBytes(dir, name, hiddenSegments = "refuse") {
156
156
  * UI's markdown/Mermaid + sandboxed-iframe renderers (issue #3) and the image
157
157
  * viewer (issue #61).
158
158
  *
159
- * For an IMAGE the raw bytes are NOT returned here (decoding binary as UTF-8
160
- * would mangle it): `content` is empty and the client fetches the bytes from
161
- * the raw endpoint. We still stat the file so a missing image 404s. Path-
162
- * traversal guarded; throws ProjectError("not_found") when missing. See
159
+ * For a BINARY kind (image, pdf) the raw bytes are NOT returned here (decoding
160
+ * binary as UTF-8 would mangle it — a PDF read this way is the garbled text of
161
+ * issue #917): `content` is empty and the client fetches the bytes from the raw
162
+ * endpoint. We still stat the file so a missing one 404s. Path-traversal
163
+ * guarded; throws ProjectError("not_found") when missing. See
163
164
  * {@link resolveInProject} for `hiddenSegments`.
164
165
  */
165
166
  export async function readFileWithKind(dir, name, hiddenSegments = "refuse") {
166
167
  const kind = fileKind(name);
167
- if (kind === "image") {
168
+ if (kind === "image" || kind === "pdf") {
168
169
  // Existence check only — the bytes go over the raw endpoint.
169
170
  try {
170
171
  await fs.stat(resolveInProject(dir, name, hiddenSegments));
@@ -22,11 +22,11 @@ export declare const IMAGE_MIME: Record<string, string>;
22
22
  export declare const VIDEO_MIME: Record<string, string>;
23
23
  /**
24
24
  * Non-image document extensions → their MIME type. Kept SEPARATE from
25
- * `IMAGE_MIME` on purpose: the file-kind classifier (`fileKind`) treats every
26
- * `IMAGE_MIME` entry as `kind: "image"`, so a `.pdf` must not live there. It's
27
- * used only for the byte endpoint's Content-Type (a PDF must serve as
28
- * `application/pdf`, not the octet-stream the attachment store rewrites to
29
- * `text/plain`).
25
+ * `IMAGE_MIME` on purpose: the file-kind classifier (`fileKind`) maps every
26
+ * `IMAGE_MIME` entry to `kind: "image"`, so a `.pdf` must not live there — it
27
+ * gets its own `kind: "pdf"` (issue #917). Also the byte endpoint's
28
+ * Content-Type (a PDF must serve as `application/pdf`, not the octet-stream the
29
+ * attachment store rewrites to `text/plain`).
30
30
  */
31
31
  export declare const DOCUMENT_MIME: Record<string, string>;
32
32
  /** Derive a render kind from a file name's extension. */
@@ -28,11 +28,11 @@ export const VIDEO_MIME = {
28
28
  };
29
29
  /**
30
30
  * Non-image document extensions → their MIME type. Kept SEPARATE from
31
- * `IMAGE_MIME` on purpose: the file-kind classifier (`fileKind`) treats every
32
- * `IMAGE_MIME` entry as `kind: "image"`, so a `.pdf` must not live there. It's
33
- * used only for the byte endpoint's Content-Type (a PDF must serve as
34
- * `application/pdf`, not the octet-stream the attachment store rewrites to
35
- * `text/plain`).
31
+ * `IMAGE_MIME` on purpose: the file-kind classifier (`fileKind`) maps every
32
+ * `IMAGE_MIME` entry to `kind: "image"`, so a `.pdf` must not live there — it
33
+ * gets its own `kind: "pdf"` (issue #917). Also the byte endpoint's
34
+ * Content-Type (a PDF must serve as `application/pdf`, not the octet-stream the
35
+ * attachment store rewrites to `text/plain`).
36
36
  */
37
37
  export const DOCUMENT_MIME = {
38
38
  ".pdf": "application/pdf",
@@ -46,6 +46,8 @@ export function fileKind(name) {
46
46
  return "html";
47
47
  if (ext in IMAGE_MIME)
48
48
  return "image";
49
+ if (ext in DOCUMENT_MIME)
50
+ return "pdf";
49
51
  return "text";
50
52
  }
51
53
  /** The MIME type for a file name's extension, defaulting to octet-stream. */
@@ -23,6 +23,21 @@ export type WorkspaceKey = string;
23
23
  export declare const ROOT_KEY: WorkspaceKey;
24
24
  /** Whether a key addresses the root workspace (i.e. `projectsRoot` itself). */
25
25
  export declare function isRootKey(key: WorkspaceKey): boolean;
26
+ /**
27
+ * The root workspace's title when nothing has been set (#921).
28
+ *
29
+ * A project falls back to its slug; the root has none, so it used to read as
30
+ * `basename(projectsRoot)` — which surfaced a filesystem detail as a title:
31
+ * lowercase `projects` by default, and whatever `PADDOCK_PROJECTS_DIR` happens
32
+ * to be called otherwise. "Home" is what the rest of the app already calls this
33
+ * place (the side-nav row and the workspace's own tab label), so the title bar
34
+ * now agrees with them.
35
+ *
36
+ * It is a DEFAULT, never a stored value: `normalize` applies it on read, and
37
+ * `writeYaml` keeps it off disk (see there), so a later instance-name config
38
+ * field has one obvious place to feed and nothing baked in to fight.
39
+ */
40
+ export declare const ROOT_DEFAULT_NAME = "Home";
26
41
  /**
27
42
  * The root workspace's name in the **herdctl agent namespace**.
28
43
  *
@@ -16,6 +16,21 @@ export const ROOT_KEY = "";
16
16
  export function isRootKey(key) {
17
17
  return key === ROOT_KEY;
18
18
  }
19
+ /**
20
+ * The root workspace's title when nothing has been set (#921).
21
+ *
22
+ * A project falls back to its slug; the root has none, so it used to read as
23
+ * `basename(projectsRoot)` — which surfaced a filesystem detail as a title:
24
+ * lowercase `projects` by default, and whatever `PADDOCK_PROJECTS_DIR` happens
25
+ * to be called otherwise. "Home" is what the rest of the app already calls this
26
+ * place (the side-nav row and the workspace's own tab label), so the title bar
27
+ * now agrees with them.
28
+ *
29
+ * It is a DEFAULT, never a stored value: `normalize` applies it on read, and
30
+ * `writeYaml` keeps it off disk (see there), so a later instance-name config
31
+ * field has one obvious place to feed and nothing baked in to fight.
32
+ */
33
+ export const ROOT_DEFAULT_NAME = "Home";
19
34
  /**
20
35
  * The root workspace's name in the **herdctl agent namespace**.
21
36
  *
@@ -25,7 +25,7 @@ export declare const PROJECT_STATUSES: readonly ["idea", "active", "paused", "bl
25
25
  export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
26
26
  export type ProjectVisibility = "public" | "private";
27
27
  /** Render-kind hint for a project file (drives the UI renderer choice). */
28
- export type FileKind = "markdown" | "html" | "text" | "image";
28
+ export type FileKind = "markdown" | "html" | "text" | "image" | "pdf";
29
29
  /**
30
30
  * One entry in a project directory listing (issue #259): a name plus whether
31
31
  * it's a file or a subdirectory, so the Files tab can distinguish the two and
@@ -1,7 +1,7 @@
1
1
  import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor } from "./project-mime.js";
2
2
  export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
3
- import { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError } from "./project-paths.js";
4
- export { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
3
+ import { ROOT_KEY, ROOT_DEFAULT_NAME, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError } from "./project-paths.js";
4
+ export { ROOT_KEY, ROOT_DEFAULT_NAME, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
5
5
  import { type Project, type FileKind, type FileEntry, type CreateProjectInput, type UpdateProjectInput } from "./project-types.js";
6
6
  export * from "./project-types.js";
7
7
  export declare class ProjectStore {
@@ -36,8 +36,8 @@ import { PROJECT_SCHEMA_VERSION, SCHEMA_VERSION_KEY, projectSchemaSkip, } from "
36
36
  // them at `./projects.js` — the same one-place-to-import discipline as before.
37
37
  import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor, } from "./project-mime.js";
38
38
  export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
39
- import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
40
- export { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
39
+ import { SLUG_RE, ROOT_KEY, ROOT_DEFAULT_NAME, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
40
+ export { ROOT_KEY, ROOT_DEFAULT_NAME, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
41
41
  import { gitRemotes, normalizeRemote } from "./adoptable.js";
42
42
  import { normalizeLinks, PATCHABLE_KEYS, } from "./project-types.js";
43
43
  import * as projectFiles from "./project-files.js";
@@ -78,6 +78,36 @@ function sanitizeModelsOverride(raw) {
78
78
  }
79
79
  return ids.length > 0 ? ids : undefined;
80
80
  }
81
+ /**
82
+ * A workspace's displayed name, defaulted (#921).
83
+ *
84
+ * A project falls back to its slug. The ROOT has no slug to fall back to, so it
85
+ * used to read as `basename(projectsRoot)` — a filesystem detail in the title
86
+ * bar: lowercase `projects` by default, and whatever `PADDOCK_PROJECTS_DIR`
87
+ * happened to be called otherwise. It now reads as {@link ROOT_DEFAULT_NAME},
88
+ * the word the side-nav row and the workspace's own tab label already use. A
89
+ * name the user actually set still wins. (No instance-name config field exists
90
+ * yet; when one lands, this is the single place it should feed.)
91
+ *
92
+ * The root's default also survives a BLANK on-disk name, not just an absent one.
93
+ * The UI cannot save one — its Save button disables on an empty Name — but the
94
+ * fix ships with "delete the `name:` line by hand" as the upgrade path for an
95
+ * instance that already persisted a derived value, and a half-done edit
96
+ * (`name:` with nothing after it, `name: ""`) is the likely slip. Unset, null and
97
+ * blank all mean the same thing here, so they all read as Home rather than as an
98
+ * empty title bar.
99
+ */
100
+ function nameFor(name, key) {
101
+ if (isRootKey(key))
102
+ return name?.trim() ? name : ROOT_DEFAULT_NAME;
103
+ return name ?? key;
104
+ }
105
+ /** The on-disk record minus `name` — the root's un-persisted default (#921). */
106
+ function omitName(yaml) {
107
+ const { name: _name, ...rest } = yaml;
108
+ void _name;
109
+ return rest;
110
+ }
81
111
  export class ProjectStore {
82
112
  root;
83
113
  dataDir;
@@ -1158,10 +1188,7 @@ export class ProjectStore {
1158
1188
  normalize(p, key) {
1159
1189
  const started = p.started ?? today();
1160
1190
  return {
1161
- // The root workspace has no slug to fall back to, so it reads as its own
1162
- // directory's basename. (No instance-name config field exists yet; when
1163
- // one lands, this is the single place it should feed.)
1164
- name: p.name ?? (isRootKey(key) ? path.basename(path.resolve(this.root)) : key),
1191
+ name: nameFor(p.name, key),
1165
1192
  slug: p.slug ?? key,
1166
1193
  status: p.status ?? "active",
1167
1194
  domain: Array.isArray(p.domain) ? p.domain : [],
@@ -1320,7 +1347,16 @@ export class ProjectStore {
1320
1347
  // construction this build's shape whatever the file said before. A legacy
1321
1348
  // file therefore gains the key the next time it is saved for some other
1322
1349
  // reason — there is no backfill pass, and merely reading one writes nothing.
1323
- const body = YAML.stringify({ [SCHEMA_VERSION_KEY]: PROJECT_SCHEMA_VERSION, ...yaml });
1350
+ // A DERIVED root name never reaches the disk (#921). `normalize` runs on
1351
+ // every read and the mutators write the normalized record straight back, so
1352
+ // one unrelated settings save used to bake the fallback into project.yaml
1353
+ // permanently — where it would then outlive any later change to the default,
1354
+ // and would mask an instance-name config field if one ever lands. Filtered
1355
+ // for the ROOT only, and only when the value IS the default: an intentional
1356
+ // title is written like any other (and a user who types "Home" gets exactly
1357
+ // "Home" back from the fallback, so nothing is lost by not storing it).
1358
+ const record = isRootKey(slug) && yaml.name === ROOT_DEFAULT_NAME ? omitName(yaml) : yaml;
1359
+ const body = YAML.stringify({ [SCHEMA_VERSION_KEY]: PROJECT_SCHEMA_VERSION, ...record });
1324
1360
  await fs.writeFile(path.join(this.dirFor(slug), PROJECT_FILE), header + body, "utf8");
1325
1361
  }
1326
1362
  /**
@@ -15,6 +15,7 @@ import { ROOT_KEY } from "../projects.js";
15
15
  import { applyMessageProvenance } from "../message-provenance.js";
16
16
  import { buildProjectRuns } from "../runs.js";
17
17
  import { DEFAULT_MODEL } from "../models.js";
18
+ import { capMessages } from "../ui-config.js";
18
19
  import { projectChatsDir } from "../transcripts.js";
19
20
  import { readSubagentMessages, readSessionTokenUsageWithSubagents } from "../subagents.js";
20
21
  import { readContextSeries } from "../usage.js";
@@ -461,7 +462,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
461
462
  schema: {
462
463
  tags: ["Chats"],
463
464
  summary: "Read a project chat's messages",
464
- description: "READ-ONLY: returns the messages of a project chat, enriched with tool details and per-message provenance, plus a synthetic trailing notice if the chat dead-ended at a usage/subscription limit. Sending a message is WebSocket-only (a `chat:send` frame on GET /ws) — there is no HTTP send-message endpoint. Response: `{ messages: [...] }`.",
465
+ description: "READ-ONLY: returns the messages of a project chat, enriched with tool details and per-message provenance, plus a synthetic trailing notice if the chat dead-ended at a usage/subscription limit. Sending a message is WebSocket-only (a `chat:send` frame on GET /ws) — there is no HTTP send-message endpoint. Optional `?limit=N` returns only the trailing N messages (issue #914); omitted or `0` returns the whole transcript, which is the default and unchanged behaviour. Response: `{ messages: [...], total, truncated }`, where `total` is the full count before any cap.",
465
466
  params: {
466
467
  type: "object",
467
468
  properties: {
@@ -470,6 +471,16 @@ export function registerChatWorkspaceRoutes(app, ctx) {
470
471
  },
471
472
  required: ["slug", "sessionId"],
472
473
  },
474
+ querystring: {
475
+ type: "object",
476
+ properties: {
477
+ limit: {
478
+ type: "integer",
479
+ minimum: 0,
480
+ description: "Return only the trailing N messages. Omit (or pass 0) for the whole transcript. The SPA passes the instance's `ui.transcriptRenderLimit`; other clients are uncapped by default so the published contract is unchanged.",
481
+ },
482
+ },
483
+ },
473
484
  response: {
474
485
  200: {
475
486
  description: "Object `{ messages }` with the chat's message array.",
@@ -525,7 +536,18 @@ export function registerChatWorkspaceRoutes(app, ctx) {
525
536
  notice,
526
537
  });
527
538
  }
528
- return { messages: withProvenance };
539
+ // Transcript render cap (issue #914). The slice is deliberately the LAST
540
+ // step, after enrich/provenance/context-fill, even though slicing earlier
541
+ // would also save that work: `applyMessageProvenance` walks its markers
542
+ // with an ordered cursor from index 0 (`message-provenance.ts:274-288`),
543
+ // so handing it a head-sliced list desyncs the cursor and silently drops
544
+ // attribution for the whole window. Slicing here means every message the
545
+ // client receives is byte-identical to the uncapped response — the only
546
+ // difference is how many of them there are. Moving the cut earlier is a
547
+ // real optimisation, but it needs a marker offset plumbed through first.
548
+ const total = withProvenance.length;
549
+ const capped = capMessages(withProvenance, req.query.limit ?? 0);
550
+ return { messages: capped, total, truncated: capped.length < total };
529
551
  }
530
552
  catch (err) {
531
553
  return sendProjectError(reply, err);
@@ -1,6 +1,7 @@
1
1
  import { sendProjectError } from "../route-errors.js";
2
2
  import { ProjectError } from "../project-paths.js";
3
3
  import { readFileWithKind, readFileBytes } from "../project-files.js";
4
+ import { cspFor } from "../http-bytes.js";
4
5
  export function registerGitRoutes(app, ctx) {
5
6
  const { git, githubAuth } = ctx;
6
7
  // --- git (backing store): fleet-level remote + connection state --------
@@ -358,7 +359,9 @@ export function registerGitWorkspaceRoutes(app, ctx) {
358
359
  .header("content-type", mime)
359
360
  .header("content-disposition", "inline")
360
361
  .header("x-content-type-options", "nosniff")
361
- .header("content-security-policy", "sandbox; default-src 'none'")
362
+ // Per-MIME, not a hard-coded constant: a bare `sandbox` token stops
363
+ // the browser's native PDF viewer painting at all (issue #917).
364
+ .header("content-security-policy", cspFor(mime))
362
365
  .header("cache-control", "private, max-age=60")
363
366
  .send(bytes);
364
367
  }
@@ -230,7 +230,7 @@ export function registerMetaRoutes(app, ctx) {
230
230
  schema: {
231
231
  tags: ["System"],
232
232
  summary: "Selectable models and instance defaults",
233
- description: "Returns the models this instance offers (the built-in catalog filtered to the `PADDOCK_MODELS` / YAML `models:` allow-list; unset ⇒ the whole catalog) plus the default model and the box-wide defaults (drive mode, max spawn depth, recovery, attachments, curation) that projects fall back to when their own overrides are unset. `defaultModel` is the effective default for the offered list. Returns a JSON object with `models`, `defaultModel`, `driveModeDefault`, `maxSpawnDepthDefault`, `recoveryDefault`, `attachmentsDefault`, and `curationDefault`.",
233
+ description: "Returns the models this instance offers (the built-in catalog filtered to the `PADDOCK_MODELS` / YAML `models:` allow-list; unset ⇒ the whole catalog) plus the default model and the box-wide defaults (drive mode, max spawn depth, recovery, attachments, curation) that projects fall back to when their own overrides are unset. `defaultModel` is the effective default for the offered list. Returns a JSON object with `models`, `defaultModel`, `driveModeDefault`, `maxSpawnDepthDefault`, `recoveryDefault`, `attachmentsDefault`, `curationDefault`, and `uiDefault`.",
234
234
  response: {
235
235
  200: {
236
236
  description: "Model list and inherited defaults.",
@@ -263,6 +263,10 @@ export function registerMetaRoutes(app, ctx) {
263
263
  // when its own `curation` override fields are unset (issue #384). Settings
264
264
  // shows these as the "Instance default" for each per-file token budget.
265
265
  curationDefault: cfg.curation,
266
+ // Instance-level UI knobs (issue #914). Unlike its neighbours this has no
267
+ // per-project override — the transcript render cap is a property of the
268
+ // operator's browser tolerance, not of a project — so the SPA uses it as-is.
269
+ uiDefault: cfg.ui,
266
270
  };
267
271
  });
268
272
  app.get("/api/fleet", {