@edspencer/paddock 0.71.2 → 0.72.1

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 (106) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.js +27 -0
  3. package/packages/server/dist/cli/args.d.ts +56 -5
  4. package/packages/server/dist/cli/args.js +193 -31
  5. package/packages/server/dist/cli/config.d.ts +157 -0
  6. package/packages/server/dist/cli/config.js +462 -0
  7. package/packages/server/dist/cli/paddock.js +53 -3
  8. package/packages/server/dist/config.d.ts +36 -1
  9. package/packages/server/dist/config.js +59 -44
  10. package/packages/server/dist/herdctl.d.ts +45 -0
  11. package/packages/server/dist/herdctl.js +58 -4
  12. package/packages/server/dist/instance-config.d.ts +149 -21
  13. package/packages/server/dist/instance-config.js +228 -5
  14. package/packages/server/dist/last-activity.d.ts +21 -0
  15. package/packages/server/dist/last-activity.js +7 -2
  16. package/packages/server/dist/profiles.d.ts +154 -0
  17. package/packages/server/dist/profiles.js +214 -0
  18. package/packages/server/dist/routes/transcripts.d.ts +61 -0
  19. package/packages/server/dist/routes/transcripts.js +676 -0
  20. package/packages/server/dist/routes.d.ts +3 -0
  21. package/packages/server/dist/routes.js +2 -0
  22. package/packages/server/dist/transcript-move.d.ts +62 -0
  23. package/packages/server/dist/transcript-move.js +179 -0
  24. package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
  25. package/packages/server/dist/transcripts-migration-execute.js +630 -0
  26. package/packages/server/dist/transcripts-migration.d.ts +336 -0
  27. package/packages/server/dist/transcripts-migration.js +917 -0
  28. package/packages/server/dist/transcripts.d.ts +46 -2
  29. package/packages/server/dist/transcripts.js +126 -3
  30. package/packages/server/package.json +1 -1
  31. package/packages/web/dist/assets/DiscoverPage-DhrXvgkl.js +2 -0
  32. package/packages/web/dist/assets/DiscoverView-yPLCp_YE.js +2 -0
  33. package/packages/web/dist/assets/InstanceConfigPage-aOgiBFLq.js +2 -0
  34. package/packages/web/dist/assets/{MarkdownRenderer-DA5CpdGI.js → MarkdownRenderer-BjrB0TDO.js} +1 -1
  35. package/packages/web/dist/assets/ProjectMenu-PhwXTmgn.js +2 -0
  36. package/packages/web/dist/assets/{ProjectRedirect-k10uM_63.js → ProjectRedirect-N4g7WrZm.js} +1 -1
  37. package/packages/web/dist/assets/ProjectView-B-7AIt--.js +13 -0
  38. package/packages/web/dist/assets/{ProjectsGrid-DrzZ7R8M.js → ProjectsGrid-DZF5VHJF.js} +1 -1
  39. package/packages/web/dist/assets/RootHome-C9wEtWHu.js +2 -0
  40. package/packages/web/dist/assets/Surfaces-BidCkENw.js +2 -0
  41. package/packages/web/dist/assets/{arc-BKAVFR26.js → arc-BctfAqLY.js} +1 -1
  42. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BJgwSqOO.js +37 -0
  43. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Eu4BV7Ea.js → blockDiagram-GPEHLZMM-BD2skmST.js} +1 -1
  44. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DR8PZaoa.js → c4Diagram-AAUBKEIU-DckMrhMv.js} +1 -1
  45. package/packages/web/dist/assets/channel-CsZOz8jm.js +2 -0
  46. package/packages/web/dist/assets/{chunk-2J33WTMH-CgvIIrI1.js → chunk-2J33WTMH-CQDUH7rp.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-4BX2VUAB-CyJWrZqN.js → chunk-4BX2VUAB-Bae1J961.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-55IACEB6-DXEnZN1d.js → chunk-55IACEB6-CXxS5wOa.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-727SXJPM-Co89QTmp.js → chunk-727SXJPM-DSNFkPB3.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BRxTzfev.js → chunk-AQP2D5EJ-B2mqFyB7.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-FMBD7UC4-DUj18CvF.js → chunk-FMBD7UC4-BfqEy3lu.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-ND2GUHAM-BQMMJobN.js → chunk-ND2GUHAM-Bm6CBVK7.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-QZHKN3VN-COx8LZCe.js → chunk-QZHKN3VN-NmnLtgYc.js} +1 -1
  54. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DdALkhE9.js +2 -0
  55. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DdALkhE9.js +2 -0
  56. package/packages/web/dist/assets/{core-DnpcT0s6.js → core-YKeFSK1q.js} +1 -1
  57. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-qf_mXpa9.js +2 -0
  58. package/packages/web/dist/assets/{dagre-BM42HDAG-CuaYJexd.js → dagre-BM42HDAG-C31v-j3s.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-2AECGRRQ-C9_anxrA.js → diagram-2AECGRRQ-C_TiPHdS.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-5GNKFQAL-Ckf3kFlY.js → diagram-5GNKFQAL-BC8gUFK-.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-KO2AKTUF-BrpUuY5x.js → diagram-KO2AKTUF-vO_QYWxi.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-LMA3HP47-B9lyXZXW.js → diagram-LMA3HP47-vxJ-YZOg.js} +1 -1
  63. package/packages/web/dist/assets/{diagram-OG6HWLK6-DT7ro32m.js → diagram-OG6HWLK6-BNIfJG6l.js} +1 -1
  64. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ZZn9bHQh.js → erDiagram-TEJ5UH35-B37HsOFM.js} +1 -1
  65. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BxQ82PY_.js → flowDiagram-I6XJVG4X-DqA5X81H.js} +1 -1
  66. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BMP0zpIg.js +293 -0
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BlwODdH9.js → gitGraphDiagram-PVQCEYII-P0nXaCzx.js} +1 -1
  68. package/packages/web/dist/assets/index-BkY_Nc_i.css +1 -0
  69. package/packages/web/dist/assets/index-C3MdluUb.js +71 -0
  70. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-nanSBFyG.js → infoDiagram-5YYISTIA-DpKYQ7VK.js} +1 -1
  71. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-yZHr_s28.js → ishikawaDiagram-YF4QCWOH-BZeRMg3C.js} +1 -1
  72. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BKjDqQEQ.js → journeyDiagram-JHISSGLW-Cg2O0qzv.js} +1 -1
  73. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-QusvHqc6.js → kanban-definition-UN3LZRKU-DBwVxUvI.js} +1 -1
  74. package/packages/web/dist/assets/{linear-4xRoD38Z.js → linear-R8qRwYqx.js} +1 -1
  75. package/packages/web/dist/assets/{mermaid.core-CeulYvDv.js → mermaid.core-jctTs2en.js} +4 -4
  76. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DSjYt2Kp.js → mindmap-definition-RKZ34NQL-CtmsNfpb.js} +1 -1
  77. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Ci0gO3CA.js → pieDiagram-4H26LBE5-C-UiLFDv.js} +1 -1
  78. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-y2qHkaHP.js → quadrantDiagram-W4KKPZXB-BS4SWa01.js} +1 -1
  79. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D9hiWbad.js → requirementDiagram-4Y6WPE33-LPPAC6Q1.js} +1 -1
  80. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DA3Mvoze.js → sankeyDiagram-5OEKKPKP-tjF8KyS3.js} +1 -1
  81. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DWACrljw.js → sequenceDiagram-3UESZ5HK-kmddkdk2.js} +1 -1
  82. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CFGEhoTb.js → stateDiagram-AJRCARHV-B07mykTY.js} +1 -1
  83. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-r6WdX0dw.js +2 -0
  84. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Cjygpd9e.js → timeline-definition-PNZ67QCA-DrUgi0cT.js} +1 -1
  85. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-zQnLaxgv.js → vennDiagram-CIIHVFJN-Dz6p0m7l.js} +1 -1
  86. package/packages/web/dist/assets/{wardley-L42UT6IY-DGmt-Lo2.js → wardley-L42UT6IY-DM16lA5x.js} +1 -1
  87. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Dj3vDukN.js → wardleyDiagram-YWT4CUSO-CCFQkFGB.js} +1 -1
  88. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C0bGbwbL.js → xychartDiagram-2RQKCTM6-DiUI5Dos.js} +1 -1
  89. package/packages/web/dist/index.html +2 -2
  90. package/packages/web/dist/sw.js +1 -1
  91. package/packages/web/dist/assets/Chip-Cp32dDmz.js +0 -2
  92. package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +0 -2
  93. package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +0 -2
  94. package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +0 -2
  95. package/packages/web/dist/assets/ProjectMenu-DXXXtXeh.js +0 -2
  96. package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +0 -13
  97. package/packages/web/dist/assets/RootHome-rWodzOC8.js +0 -2
  98. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-UzIlxaCQ.js +0 -37
  99. package/packages/web/dist/assets/channel-C-sodtn7.js +0 -2
  100. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +0 -2
  101. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +0 -2
  102. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-B-4NadKI.js +0 -2
  103. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BxliloOH.js +0 -293
  104. package/packages/web/dist/assets/index-Cx9y6c3f.js +0 -71
  105. package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
  106. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +0 -2
@@ -38,7 +38,8 @@ import { DEFAULT_ATTACHMENTS, sanitizeAllowedTypes } from "./attachments-config.
38
38
  import { DEFAULT_CURATION } from "./curation-config.js";
39
39
  import { DEFAULT_ENVIRONMENT_PROMPT } from "./environment-prompt.js";
40
40
  import { CONFIG_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schema-version.js";
41
- import { DEFAULT_TRANSCRIPTS_MODE } from "./transcripts.js";
41
+ import { DEFAULT_PROFILE, POSTURE_KEYS, PROFILE_NAMES, isKnownProfile, posture as postureFor, } from "./profiles.js";
42
+ import { DEFAULT_TRANSCRIPTS_MODE, isKnownTranscriptsMode, } from "./transcripts.js";
42
43
  import { DEFAULT_CREDENTIALS_MODE } from "./claude-credentials.js";
43
44
  import { DEFAULT_INSTRUCTIONS_MODE } from "./claude-instructions.js";
44
45
  import { DEFAULT_HOOKS_MODE } from "./claude-settings.js";
@@ -54,7 +55,7 @@ export const GROUPS = [
54
55
  { id: "transcription", label: "Transcription" },
55
56
  { id: "git", label: "Git identity" },
56
57
  { id: "logging", label: "Logging" },
57
- { id: "advanced", label: "Advanced (read-only)", description: "Process / filesystem bindings. Change these via env/redeploy, not the UI." },
58
+ { id: "advanced", label: "Advanced (read-only)", description: "The instance's posture profile, its process / filesystem bindings, and what it shares with the Claude Code CLI on this machine. Change these via env/redeploy, not the UI." },
58
59
  ];
59
60
  // --- coercion helpers -------------------------------------------------------
60
61
  const asBool = (raw) => typeof raw === "boolean" ? { ok: true, value: raw } : { ok: false, error: "must be a boolean" };
@@ -284,6 +285,21 @@ export const FIELDS = [
284
285
  // Logging.
285
286
  { key: "logLevel", group: "logging", label: "Log level", type: "enum", enumValues: LOG_LEVELS, envVars: ["LOG_LEVEL"], default: "info", editable: true, coerce: oneOf(LOG_LEVELS) },
286
287
  // Advanced — read-only display (process / filesystem bindings).
288
+ //
289
+ // The posture profile (#878) leads the group because it is the row that
290
+ // EXPLAINS the others: it supplies the default for the five `claude.` modes
291
+ // below and for seven of the Capabilities toggles above, and every field it is
292
+ // actually responsible for is reported with `fromProfile` so the UI can say so
293
+ // on the row itself.
294
+ //
295
+ // READ-ONLY, and not for the usual advanced-group reason. A writable profile
296
+ // row would set exactly the five `claude.` keys below — the ones that are
297
+ // read-only because their symlinks are planted at agent-registration time, so
298
+ // a live toggle silently does nothing until the next boot. Making the profile
299
+ // editable would reintroduce that through the back door, and worse: one
300
+ // control that no-ops five levers at once. Change it in `paddock.config.yaml`
301
+ // or `PADDOCK_PROFILE` and restart, which is what the value here reflects.
302
+ { key: "profile", group: "advanced", label: "Posture profile", help: "The named security/capability posture this instance resolved at boot. It sets the default for the Claude sharing modes below and most of the Capabilities toggles; anything set explicitly in this file or the environment still wins over it.", type: "enum", enumValues: PROFILE_NAMES, envVars: ["PADDOCK_PROFILE"], default: DEFAULT_PROFILE, editable: false },
287
303
  { key: "port", group: "advanced", label: "Port", type: "number", envVars: ["PORT"], default: 7233, editable: false },
288
304
  { key: "host", group: "advanced", label: "Host", type: "string", envVars: ["HOST", "PADDOCK_HOST"], default: "127.0.0.1", editable: false },
289
305
  { key: "dataDir", group: "advanced", label: "Data dir", type: "string", envVars: ["PADDOCK_DATA_DIR"], default: null, editable: false },
@@ -396,6 +412,32 @@ function readConfigFileSnapshot(configPath) {
396
412
  export function instanceConfigVersion(configPath) {
397
413
  return readConfigFileSnapshot(configPath).version;
398
414
  }
415
+ /**
416
+ * The transcripts mode a RESTART would resolve — read from the config FILE,
417
+ * falling back to the profile's default when the file is silent about it.
418
+ *
419
+ * NOT `cfg.claude.transcripts`, which is what THIS process froze at boot. The
420
+ * two differ for the whole window between a successful `own → host` migration
421
+ * (#882) and the restart it asks for, and that window is precisely when a
422
+ * second POST arrives: without this the idempotency check would see `own`,
423
+ * decide nothing had happened, and re-run a migration that already had.
424
+ *
425
+ * The environment is deliberately not consulted. `PADDOCK_CLAUDE_TRANSCRIPTS`
426
+ * beats the file, so when it is set the true answer to "what would a restart
427
+ * resolve" is the env var — but a caller in that situation is already refusing
428
+ * with `env_shadowed`, and folding it in here would make a shadowed instance
429
+ * look permanently migrated.
430
+ */
431
+ export function pendingTranscriptsMode(cfg) {
432
+ const file = readConfigFileSnapshot(instanceConfigPath(cfg));
433
+ const raw = file.data ? readPath(file.data, "claude.transcripts") : undefined;
434
+ if (typeof raw === "string" && isKnownTranscriptsMode(raw))
435
+ return raw;
436
+ const fallback = postureFor(cfg.profile).transcripts;
437
+ return typeof fallback === "string" && isKnownTranscriptsMode(fallback)
438
+ ? fallback
439
+ : DEFAULT_TRANSCRIPTS_MODE;
440
+ }
399
441
  /** Structural equality for the value shapes a field can hold (scalars + string lists). */
400
442
  function valuesEqual(a, b) {
401
443
  if (Array.isArray(a) || Array.isArray(b)) {
@@ -445,7 +487,72 @@ export function instanceConfigPath(cfg) {
445
487
  * friends are canonicalised, `port` is `Number()`-ed), so comparing them
446
488
  * against raw file text would manufacture divergence that isn't there.
447
489
  */
490
+ /**
491
+ * What this field falls back to when nothing sets it — which, for the twelve
492
+ * posture keys, is whatever the instance's profile says rather than the static
493
+ * `default` on the spec (#878).
494
+ *
495
+ * Not cosmetic: `default` is also what `pendingRestart` is computed against. A
496
+ * config file that simply omits `selfMcpEnabled` is *agreeing* with the profile,
497
+ * and scoring it against a hardcoded `false` would report a pending restart on a
498
+ * file that changes nothing.
499
+ *
500
+ * The mapping is by name — every posture field's key IS its `Posture` key,
501
+ * modulo the `claude.` prefix — so a lever added to both `FIELDS` and `Posture`
502
+ * is picked up here with no third list to keep in sync.
503
+ */
504
+ function defaultFor(f, p) {
505
+ const k = postureKeyOf(f);
506
+ return k === null ? f.default : p[k];
507
+ }
508
+ /**
509
+ * The {@link Posture} key a field is a lever for, or `null` if the profile has
510
+ * no opinion about it. The mapping is by name — every posture field's key IS its
511
+ * `Posture` key, modulo the `claude.` prefix — so a lever added to both `FIELDS`
512
+ * and `Posture` is picked up with no third list to keep in sync.
513
+ */
514
+ function postureKeyOf(f) {
515
+ const k = f.key.startsWith("claude.") ? f.key.slice("claude.".length) : f.key;
516
+ return POSTURE_KEYS.includes(k) ? k : null;
517
+ }
518
+ /**
519
+ * Whether the PROFILE is what put this field's effective value there (#878) — a
520
+ * claim the UI renders as a chip on the row, so it has to be true.
521
+ *
522
+ * Every clause here exists to stop the chip appearing on a value the profile did
523
+ * not choose, because a row that blames the profile for something the operator
524
+ * set themselves is worse than a row that says nothing:
525
+ *
526
+ * - **not a posture key** — the profile is silent on port, auth, models, …
527
+ * - **not env-shadowed** — `PADDOCK_CLAUDE_INSTRUCTIONS=own` under
528
+ * `profile: balanced` resolves `own`, and the profile wanted `host`. The env
529
+ * var beat it; the existing `env` chip is the honest label for that row.
530
+ * - **not set in the file** — an explicit `claude: {hooks: host}` beats the
531
+ * profile too (specific beats general — see `profiles.ts`), even when it
532
+ * happens to agree.
533
+ * - **value actually matches the posture** — belt-and-braces against a lever
534
+ * that is in `POSTURE_KEYS` but whose loader never consulted the profile.
535
+ * That is a real bug class, and the failure mode without this clause is the
536
+ * screen confidently mislabelling it.
537
+ *
538
+ * `fileUnknown` covers the one state where we genuinely cannot tell: the config
539
+ * file exists but no longer parses. It parsed at boot (config.ts throws
540
+ * otherwise, so the process would not be running), which means the file in force
541
+ * is one we cannot read — so we decline to attribute rather than guess.
542
+ */
543
+ function isFromProfile(f, p, value, envShadowed, file) {
544
+ const k = postureKeyOf(f);
545
+ if (k === null || envShadowed)
546
+ return false;
547
+ const fileUnknown = file.data === null && file.version !== null;
548
+ if (fileUnknown)
549
+ return false;
550
+ if (readPath(file.data ?? {}, f.key) != null)
551
+ return false;
552
+ return valuesEqual(value, p[k]);
553
+ }
448
554
  export function buildInstanceConfig(cfg) {
555
+ const p = postureFor(cfg.profile);
449
556
  const groups = GROUPS.map((g) => ({
450
557
  id: g.id,
451
558
  label: g.label,
@@ -468,12 +575,12 @@ export function buildInstanceConfig(cfg) {
468
575
  // Absent, or present-but-null (`debounceMs:` with nothing after it), both
469
576
  // mean "no override" — `fileOr`/`fileOpt` degrade a null to the default.
470
577
  const fileRaw = readPath(file.data, f.key);
471
- pendingValue = fileRaw == null ? (f.default ?? null) : coerceForDisplay(f, fileRaw);
578
+ pendingValue = fileRaw == null ? (defaultFor(f, p) ?? null) : coerceForDisplay(f, fileRaw);
472
579
  // A field the frozen config leaves unset IS its built-in default (that is
473
580
  // what `default` documents), so normalise before comparing — otherwise
474
581
  // e.g. `models`, unset and therefore null, would read as forever diverging
475
582
  // from the catalog list the file's absence implies.
476
- const effective = value === null ? (f.default ?? null) : value;
583
+ const effective = value === null ? (defaultFor(f, p) ?? null) : value;
477
584
  pendingRestart = !valuesEqual(pendingValue, effective);
478
585
  if (pendingRestart)
479
586
  restartRequired = true;
@@ -488,11 +595,12 @@ export function buildInstanceConfig(cfg) {
488
595
  value,
489
596
  pendingValue,
490
597
  pendingRestart,
491
- default: f.default,
598
+ default: defaultFor(f, p),
492
599
  editable: f.editable,
493
600
  sensitive: f.sensitive ?? false,
494
601
  envOverridden: shadow !== undefined,
495
602
  envVar: shadow,
603
+ fromProfile: isFromProfile(f, p, value, shadow !== undefined, file),
496
604
  };
497
605
  groupById.get(f.group)?.fields.push(dto);
498
606
  }
@@ -611,3 +719,118 @@ export function writeInstanceConfig(configPath, pairs) {
611
719
  fs.writeFileSync(tmp, serialized, "utf8");
612
720
  fs.renameSync(tmp, configPath);
613
721
  }
722
+ /**
723
+ * Fields whose effective value is a NORMALISED form of whatever the file said —
724
+ * paths get `abs()` + `canonical()` at boot, so `dataDir: ./data` legitimately
725
+ * resolves to `/home/you/data`. Comparing those two would manufacture a
726
+ * "your file value is not in effect" warning on a file that is working fine, so
727
+ * for these keys the file's mere presence settles provenance.
728
+ */
729
+ const NORMALISED_AT_BOOT = new Set([
730
+ "dataDir",
731
+ "projectsRoot",
732
+ "stateDir",
733
+ "herdctlConfigPath",
734
+ "webDist",
735
+ ]);
736
+ /**
737
+ * Did the file's value survive into the resolved config?
738
+ *
739
+ * Looser than {@link valuesEqual} for scalars on purpose: every file value goes
740
+ * through `String()` in `fileOr`/`fileOpt` before it is parsed, so a hand-typed
741
+ * `port: "7300"` and a resolved `7300` are the same decision, not a divergence.
742
+ */
743
+ function fileValueTookEffect(written, effective) {
744
+ if (valuesEqual(written, effective))
745
+ return true;
746
+ if (written == null || effective == null)
747
+ return false;
748
+ if (typeof written === "object" || typeof effective === "object")
749
+ return false;
750
+ return String(written) === String(effective);
751
+ }
752
+ /**
753
+ * Where the profile NAME came from. Mirrors `resolveProfileName`'s precedence
754
+ * exactly — a non-blank `PADDOCK_PROFILE` wins outright, and notably does NOT
755
+ * fall through to the file when it names something unrecognised.
756
+ */
757
+ function resolveProfileProvenance(name, fileData, configPath) {
758
+ const envRaw = process.env.PADDOCK_PROFILE?.trim();
759
+ const fileVal = fileData?.profile;
760
+ const fileRaw = fileVal === undefined || fileVal === null ? "" : String(fileVal).trim();
761
+ const raw = envRaw || fileRaw;
762
+ if (!raw)
763
+ return { name, source: "default" };
764
+ const origin = envRaw ? "PADDOCK_PROFILE" : configPath;
765
+ if (isKnownProfile(raw.toLowerCase())) {
766
+ return { name, source: envRaw ? "env" : "file", origin };
767
+ }
768
+ return { name, source: "default", unrecognised: { value: raw, origin } };
769
+ }
770
+ /**
771
+ * Resolve every field in {@link FIELDS} against a loaded config, reporting the
772
+ * layer each value came from.
773
+ *
774
+ * Built on the same descriptor table the Config screen uses rather than a
775
+ * parallel list, so a lever added for the UI is printed by the CLI for free and
776
+ * the two can never disagree about what a default is. What it does NOT reuse is
777
+ * `buildInstanceConfig`'s DTO: that answers "what would a restart change?", which
778
+ * needs the file compared against the process. This answers "where did each of
779
+ * these values come from?", which needs the file compared against the LAYERS.
780
+ *
781
+ * No value here is a secret — {@link FIELDS} excludes them by construction — but
782
+ * `sensitive` rides along so a caller can decide what to put on a terminal that
783
+ * ends up pasted into an issue.
784
+ */
785
+ export function resolveConfigReport(cfg) {
786
+ const configPath = instanceConfigPath(cfg);
787
+ const file = readConfigFileSnapshot(configPath);
788
+ const p = postureFor(cfg.profile);
789
+ const fields = FIELDS.map((f) => {
790
+ const shadow = envOverride(f.envVars, f.envShadowWhenDefined);
791
+ const raw = readPath(cfg, f.key);
792
+ const fallback = defaultFor(f, p) ?? null;
793
+ const value = raw === undefined || raw === null ? fallback : raw;
794
+ const fileRaw = file.data ? readPath(file.data, f.key) : undefined;
795
+ const fileSets = fileRaw !== undefined && fileRaw !== null;
796
+ // The layer a field falls back to when nothing names it: the profile for a
797
+ // posture key, the built-in default for everything else.
798
+ const bare = f.key.startsWith("claude.") ? f.key.slice("claude.".length) : f.key;
799
+ const isPosture = POSTURE_KEYS.includes(bare);
800
+ const unset = isPosture
801
+ ? { source: "profile", origin: cfg.profile }
802
+ : { source: "default" };
803
+ let resolved;
804
+ if (shadow !== undefined) {
805
+ resolved = { source: "env", origin: shadow, ...(fileSets ? { shadowedFileValue: fileRaw } : {}) };
806
+ }
807
+ else if (fileSets) {
808
+ resolved =
809
+ NORMALISED_AT_BOOT.has(f.key) || fileValueTookEffect(coerceForDisplay(f, fileRaw), value)
810
+ ? { source: "file", origin: configPath }
811
+ : { ...unset, shadowedFileValue: fileRaw };
812
+ }
813
+ else {
814
+ resolved = unset;
815
+ }
816
+ return {
817
+ key: f.key,
818
+ group: f.group,
819
+ label: f.label,
820
+ help: f.help,
821
+ type: f.type,
822
+ value,
823
+ sensitive: f.sensitive ?? false,
824
+ ejectable: (f.editable || isPosture) && f.sensitive !== true,
825
+ ...resolved,
826
+ };
827
+ });
828
+ return {
829
+ dataDir: cfg.dataDir,
830
+ configPath,
831
+ configFileExists: file.version !== null,
832
+ ...(file.error ? { configFileError: file.error } : {}),
833
+ profile: resolveProfileProvenance(cfg.profile, file.data, configPath),
834
+ fields,
835
+ };
836
+ }
@@ -12,6 +12,27 @@ export interface SessionWithActivity extends DiscoveredSession {
12
12
  /** ISO 8601 timestamp of the last real message; absent if none was datable. */
13
13
  lastMessageAt?: string;
14
14
  }
15
+ /**
16
+ * Does this record represent a human or the agent actually saying something?
17
+ *
18
+ * The exclusions are the point of the whole issue, not defensive noise. A
19
+ * resume, a permission-mode change or a background agent finishing all append
20
+ * records to a transcript **without any turn having happened**, and counting
21
+ * those as activity reintroduces the same bug one layer up: a chat nobody has
22
+ * touched in a week, freshly stamped, back at the top of the list.
23
+ *
24
+ * Exported because the `own → host` migration preview (#882) counts the same
25
+ * records to show a diverged chat's two copies side by side. A second copy of
26
+ * this rule would drift, and the two numbers would then disagree on screen
27
+ * about what a message is.
28
+ */
29
+ export declare function isConversationRecord(rec: {
30
+ type?: unknown;
31
+ isMeta?: unknown;
32
+ origin?: {
33
+ kind?: unknown;
34
+ };
35
+ }): boolean;
15
36
  /**
16
37
  * Timestamp of the last real message in a project chat's transcript.
17
38
  *
@@ -52,8 +52,13 @@ const cache = new Map();
52
52
  * records to a transcript **without any turn having happened**, and counting
53
53
  * those as activity reintroduces the same bug one layer up: a chat nobody has
54
54
  * touched in a week, freshly stamped, back at the top of the list.
55
+ *
56
+ * Exported because the `own → host` migration preview (#882) counts the same
57
+ * records to show a diverged chat's two copies side by side. A second copy of
58
+ * this rule would drift, and the two numbers would then disagree on screen
59
+ * about what a message is.
55
60
  */
56
- function isConversation(rec) {
61
+ export function isConversationRecord(rec) {
57
62
  // Drops `summary` title entries and every `type: "system"` control line
58
63
  // (local-command records, permission-mode changes, hook output).
59
64
  if (rec.type !== "user" && rec.type !== "assistant")
@@ -103,7 +108,7 @@ function scanBackwards(text, whole) {
103
108
  catch {
104
109
  continue;
105
110
  }
106
- if (!isConversation(rec))
111
+ if (!isConversationRecord(rec))
107
112
  continue;
108
113
  const ts = rec.timestamp;
109
114
  if (typeof ts === "string" && !Number.isNaN(Date.parse(ts)))
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Named config profiles (#878) — `paranoid` / `balanced` / `yolo`.
3
+ *
4
+ * Paddock has a dozen independent security/capability levers, each with its own
5
+ * code default. A profile is the single place to express *posture* — "locked
6
+ * down" / "full capability" — instead of hand-setting twelve `PADDOCK_*` vars.
7
+ *
8
+ * ## Why this is a resolved key, not something an installer writes
9
+ *
10
+ * Config resolves as code default < YAML file < `PADDOCK_*` env. A profile
11
+ * implemented as "the installer materialises concrete values into
12
+ * `paddock.config.yaml`" only ever touches the FILE layer — so it only works on
13
+ * the one distribution that runs an installer. Docker and bare `node dist` would
14
+ * keep falling through to code defaults and the distributions would stay
15
+ * divergent, which is the actual complaint #878 opens with. Resolving `profile`
16
+ * at every layer — file, env, and a code default underneath both — is what makes
17
+ * every distribution converge on the same posture.
18
+ *
19
+ * ## Where a profile sits in the precedence chain
20
+ *
21
+ * A profile supplies the DEFAULT that each lever's loader falls back to. It does
22
+ * not become a config layer of its own. So the full chain is:
23
+ *
24
+ * code-default profile < resolved profile < individual file key < individual env var
25
+ *
26
+ * The consequence worth stating out loud, because it inverts this codebase's
27
+ * otherwise universal env-beats-file rule: **an individual key in the YAML file
28
+ * beats `PADDOCK_PROFILE` in the env.** `PADDOCK_PROFILE=paranoid` with
29
+ * `claude: {hooks: host}` in a mounted file resolves hooks to `host`.
30
+ *
31
+ * That is deliberate — specific beats general. `PADDOCK_PROFILE` is a statement
32
+ * about the dozen levers you did NOT mention; a key you wrote by hand is a
33
+ * statement about the one you did. The alternative makes the env var a blunt
34
+ * instrument that silently discards deliberate per-key config, which is a worse
35
+ * failure than the surprise of a file winning. (Env still beats file for the same
36
+ * key, exactly as everywhere else: `PADDOCK_CLAUDE_HOOKS` beats
37
+ * `claude.hooks`.)
38
+ *
39
+ * ## Posture keys only
40
+ *
41
+ * Profiles govern the `claude:` inheritance modes, `maxSpawnDepth`, and the
42
+ * capability toggles — and define ALL of them (see `POSTURE_KEYS`, pinned by a
43
+ * test). They are silent on operational keys (port, data dir, bind host, auth,
44
+ * models, drive mode, curation/recovery/attachments, git author). Switching
45
+ * profile must never change your port or clobber your auth config.
46
+ *
47
+ * ## Two guardrails
48
+ *
49
+ * - Profiles stay orthogonal to bind address and auth. `yolo` must NOT open the
50
+ * bind or relax auth — network exposure remains its own explicit decision,
51
+ * already gated by the dangerous-allow-open opt-in. Otherwise "yolo" quietly
52
+ * becomes "yolo, reachable from the whole network".
53
+ * - `host` resolves against whatever `~/.claude` actually exists: rich on a
54
+ * workstation, typically empty in a container. **The profile sets the levers,
55
+ * the environment sets the blast radius** — which is why the permissive
56
+ * profiles are far more reasonable in a disposable container than on a primary
57
+ * machine.
58
+ */
59
+ import type { CredentialsMode } from "./claude-credentials.js";
60
+ import type { HooksMode } from "./claude-settings.js";
61
+ import type { InstructionsMode } from "./claude-instructions.js";
62
+ import type { McpServersMode } from "./claude-mcp.js";
63
+ import type { TranscriptsMode } from "./transcripts.js";
64
+ /** The three built-in profiles, in ascending order of capability. */
65
+ export declare const PROFILE_NAMES: readonly ["paranoid", "balanced", "yolo"];
66
+ export type ProfileName = (typeof PROFILE_NAMES)[number];
67
+ /**
68
+ * The code-default profile (#878).
69
+ *
70
+ * `balanced` rather than `paranoid` because of the **superset principle**:
71
+ * paddock is a presentation layer over the Claude Code CLI, so its capability
72
+ * surface should be a superset of what the plain CLI already gives you. A user
73
+ * whose CLI has MCP servers configured (and plugin-provided MCP servers, which
74
+ * additionally need `instructions: host`) reasonably expects those to work here;
75
+ * defaulting them off is experienced as a capability regression versus the tool
76
+ * paddock wraps.
77
+ */
78
+ export declare const DEFAULT_PROFILE: ProfileName;
79
+ /**
80
+ * The posture surface: every lever a profile governs, and the only ones it may.
81
+ *
82
+ * Adding a capability lever means adding it here AND giving all three profiles a
83
+ * value for it — `profiles.test.ts` fails until you do. That is the point of the
84
+ * invariant: today a new toggle just gets a code default and the postures are
85
+ * never revisited, so they silently drift out of date.
86
+ */
87
+ export interface Posture {
88
+ /** `claude.transcripts` — share the host's transcript store. */
89
+ transcripts: TranscriptsMode;
90
+ /** `claude.credentials` — read the host's Claude login. */
91
+ credentials: CredentialsMode;
92
+ /** `claude.instructions` — inherit the host's CLAUDE.md hierarchy. */
93
+ instructions: InstructionsMode;
94
+ /** `claude.hooks` — inherit the host's hook definitions. */
95
+ hooks: HooksMode;
96
+ /** `claude.mcpServers` — inherit the host's configured MCP servers. */
97
+ mcpServers: McpServersMode;
98
+ /** How deep an agent may spawn sub-agents. */
99
+ maxSpawnDepth: number;
100
+ /** The read-only self-management MCP. */
101
+ selfMcpEnabled: boolean;
102
+ /** The self-management MCP WRITE tools. */
103
+ selfMcpWriteEnabled: boolean;
104
+ /** The self-management MCP PROJECT tools (`create_project`). */
105
+ selfMcpProjectsEnabled: boolean;
106
+ /** Programmatic schedule mutation. */
107
+ scheduleMutationEnabled: boolean;
108
+ /** The hook-management MCP tools. */
109
+ hooksMcpEnabled: boolean;
110
+ /** The Playwright browser MCP. */
111
+ browserMcp: boolean;
112
+ }
113
+ /**
114
+ * Every posture key, as data — so a test can assert that all three profiles
115
+ * cover exactly this set. Kept in sync with `Posture` by that same test: a key
116
+ * added to the interface but not here fails to typecheck against
117
+ * `Record<keyof Posture, …>` at the `PROFILES` declaration below.
118
+ */
119
+ export declare const POSTURE_KEYS: readonly ["transcripts", "credentials", "instructions", "hooks", "mcpServers", "maxSpawnDepth", "selfMcpEnabled", "selfMcpWriteEnabled", "selfMcpProjectsEnabled", "scheduleMutationEnabled", "hooksMcpEnabled", "browserMcp"];
120
+ /**
121
+ * The three postures. All three define the same key set, differing only in
122
+ * values — so a diff between two columns is a diff in posture, never in
123
+ * coverage.
124
+ *
125
+ * Two choices here are argued rather than assumed:
126
+ *
127
+ * - **`hooks: own` in `balanced`.** Host hooks are shell commands that fire
128
+ * AUTOMATICALLY on every matching tool call — inherited arbitrary code
129
+ * execution. That is a different risk class from MCP servers, which are tools
130
+ * an agent deliberately chooses to call. So `hooks` does not ride along with
131
+ * the other `host` modes; it is `host` only under `yolo`.
132
+ * - **`browserMcp: false` in `balanced`.** It needs a browser installed on the
133
+ * box. Enabling it by default on a host without one leads agents to fall back
134
+ * to scripting a system browser instead, which is worse than not having it.
135
+ */
136
+ export declare const PROFILES: Record<ProfileName, Posture>;
137
+ /** Whether `v` names a built-in profile. */
138
+ export declare function isKnownProfile(v: string): v is ProfileName;
139
+ /**
140
+ * Resolve the profile NAME from env > file > {@link DEFAULT_PROFILE}.
141
+ *
142
+ * An unrecognised name falls back to the default rather than failing the boot —
143
+ * the rule every other enum in `config.ts` follows (`driveMode`, the `claude:`
144
+ * modes, whisper `mode`). The direction is defensible here in a way it is not
145
+ * for a single lever: the fallback is `balanced`, so a typo can only ever land
146
+ * you on the same posture a config-less instance already has, never on `yolo`.
147
+ *
148
+ * Deliberately NOT tsconfig-style `extends`: a closed set of names, one level,
149
+ * no chains, no path resolution. The inheritance is fine; arbitrary-file
150
+ * inheritance graphs are what make `extends` unpleasant.
151
+ */
152
+ export declare function resolveProfileName(fileVal: unknown, env?: NodeJS.ProcessEnv): ProfileName;
153
+ /** The posture a profile name expands to. */
154
+ export declare function posture(name: ProfileName): Posture;