@edspencer/paddock 0.59.1 → 0.60.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 (73) hide show
  1. package/package.json +2 -2
  2. package/packages/server/dist/adoptable.d.ts +72 -3
  3. package/packages/server/dist/adoptable.js +231 -25
  4. package/packages/server/dist/herdctl.d.ts +80 -0
  5. package/packages/server/dist/herdctl.js +159 -0
  6. package/packages/server/dist/routes/chats.js +59 -3
  7. package/packages/server/dist/run-provenance.d.ts +11 -0
  8. package/packages/server/dist/run-provenance.js +18 -0
  9. package/packages/server/dist/ws-turn.d.ts +0 -13
  10. package/packages/server/dist/ws-turn.js +50 -0
  11. package/packages/server/package.json +1 -1
  12. package/packages/web/dist/assets/{InstanceConfigPage-CAwSnyga.js → InstanceConfigPage-xn9Rvoah.js} +1 -1
  13. package/packages/web/dist/assets/{MarkdownRenderer-DpGpkMzG.js → MarkdownRenderer-DMONssKA.js} +1 -1
  14. package/packages/web/dist/assets/{ProjectRedirect-D1ipv4jr.js → ProjectRedirect-DHoWtvtY.js} +1 -1
  15. package/packages/web/dist/assets/ProjectView-BmSdUwY2.js +13 -0
  16. package/packages/web/dist/assets/{ProjectsGrid-Dwngb-m1.js → ProjectsGrid-CjfwpeFj.js} +1 -1
  17. package/packages/web/dist/assets/{arc-BZh6Z9_D.js → arc-DdFdbD-c.js} +1 -1
  18. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-iCtJiW4k.js → architectureDiagram-3BPJPVTR-DC8UKtwV.js} +1 -1
  19. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BeI9Wi0h.js → blockDiagram-GPEHLZMM-DqFFe_5c.js} +1 -1
  20. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D_j2LKZ-.js → c4Diagram-AAUBKEIU-DbffFSM-.js} +1 -1
  21. package/packages/web/dist/assets/channel-QxsJGL6F.js +2 -0
  22. package/packages/web/dist/assets/{chunk-2J33WTMH-DRlfPROq.js → chunk-2J33WTMH-8PP2RHft.js} +1 -1
  23. package/packages/web/dist/assets/{chunk-4BX2VUAB-BMY66MLs.js → chunk-4BX2VUAB-Hag68B5N.js} +1 -1
  24. package/packages/web/dist/assets/{chunk-55IACEB6-BAQ92trf.js → chunk-55IACEB6-BXp_gQIa.js} +1 -1
  25. package/packages/web/dist/assets/{chunk-727SXJPM-Dxgb_nNN.js → chunk-727SXJPM-DlntjQux.js} +1 -1
  26. package/packages/web/dist/assets/{chunk-AQP2D5EJ-D5cqv3Jv.js → chunk-AQP2D5EJ-BhI9KPrE.js} +1 -1
  27. package/packages/web/dist/assets/{chunk-FMBD7UC4-BOEqxzyA.js → chunk-FMBD7UC4-WhfORs2z.js} +1 -1
  28. package/packages/web/dist/assets/{chunk-ND2GUHAM-CkTZgv_W.js → chunk-ND2GUHAM-CIskmv20.js} +1 -1
  29. package/packages/web/dist/assets/{chunk-QZHKN3VN-DIr8TL7M.js → chunk-QZHKN3VN-CNOTpCW4.js} +1 -1
  30. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CI7p84hR.js +2 -0
  31. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CI7p84hR.js +2 -0
  32. package/packages/web/dist/assets/{core-DyOZ2e1O.js → core-usOVRkLM.js} +1 -1
  33. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-D8Muyf4o.js → cose-bilkent-S5V4N54A-CniuMIIW.js} +1 -1
  34. package/packages/web/dist/assets/{dagre-BM42HDAG-B_EUbUz6.js → dagre-BM42HDAG-5CteYfQY.js} +1 -1
  35. package/packages/web/dist/assets/{diagram-2AECGRRQ-DpAM0K6Q.js → diagram-2AECGRRQ-BIQjD2LA.js} +1 -1
  36. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dhs9RMKk.js → diagram-5GNKFQAL-1vy3ErTt.js} +1 -1
  37. package/packages/web/dist/assets/{diagram-KO2AKTUF-LBeTHReu.js → diagram-KO2AKTUF-D45iqFk6.js} +1 -1
  38. package/packages/web/dist/assets/{diagram-LMA3HP47-CxboRwue.js → diagram-LMA3HP47-DNktDktn.js} +1 -1
  39. package/packages/web/dist/assets/{diagram-OG6HWLK6-BK_P-OSi.js → diagram-OG6HWLK6-BQ9JgV70.js} +1 -1
  40. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CPSKRHI5.js → erDiagram-TEJ5UH35-oBasZ1uv.js} +1 -1
  41. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CbUumAP5.js → flowDiagram-I6XJVG4X-DxnJHMBS.js} +1 -1
  42. package/packages/web/dist/assets/{format-DDL0Q7H2.js → format-CIAat6sU.js} +1 -1
  43. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-l-p25ckA.js → ganttDiagram-6RSMTGT7-CtJQ2m7z.js} +1 -1
  44. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-i8kDIgga.js → gitGraphDiagram-PVQCEYII-5RPscwD_.js} +1 -1
  45. package/packages/web/dist/assets/index-B6etJCHf.css +1 -0
  46. package/packages/web/dist/assets/{index-DKDgTDXa.js → index-DtRblEY_.js} +4 -4
  47. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CbyLnHIe.js → infoDiagram-5YYISTIA-zQu3xkW4.js} +1 -1
  48. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-C1_3z8-9.js → ishikawaDiagram-YF4QCWOH-ChoEjcdw.js} +1 -1
  49. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BzyAtQdy.js → journeyDiagram-JHISSGLW-DM2KWmDr.js} +1 -1
  50. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Fp2VVm-b.js → kanban-definition-UN3LZRKU-DWzTpYgh.js} +1 -1
  51. package/packages/web/dist/assets/{linear-6ZKEeWQ5.js → linear-Bpy9Aj-K.js} +1 -1
  52. package/packages/web/dist/assets/{mermaid.core-Dorg4Dl2.js → mermaid.core-D43x5bFb.js} +4 -4
  53. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bk4Ax0bI.js → mindmap-definition-RKZ34NQL-BhceB-cv.js} +1 -1
  54. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DoTn5jHm.js → pieDiagram-4H26LBE5-BgcGlFFA.js} +1 -1
  55. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ClNmIKCj.js → quadrantDiagram-W4KKPZXB-ZRVaOoSp.js} +1 -1
  56. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DMF7i5KW.js → requirementDiagram-4Y6WPE33-2hHcJHBt.js} +1 -1
  57. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-C-ALbVLg.js → sankeyDiagram-5OEKKPKP-KPUQ9Mu5.js} +1 -1
  58. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DsUxj8IQ.js → sequenceDiagram-3UESZ5HK-D4BXQSSM.js} +1 -1
  59. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-OCZiRRsz.js → stateDiagram-AJRCARHV-DftS7dIo.js} +1 -1
  60. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CgBC1jpO.js +2 -0
  61. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-5ifjxa-s.js → timeline-definition-PNZ67QCA-C0OAxyWY.js} +1 -1
  62. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Bqvqpot5.js → vennDiagram-CIIHVFJN-C94fINN3.js} +1 -1
  63. package/packages/web/dist/assets/{wardley-L42UT6IY-DwgnVd48.js → wardley-L42UT6IY-CbFrlwHE.js} +1 -1
  64. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-F-VCH-oE.js → wardleyDiagram-YWT4CUSO-CHG2nndd.js} +1 -1
  65. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-L4q6oV_7.js → xychartDiagram-2RQKCTM6-U9ESc19z.js} +1 -1
  66. package/packages/web/dist/index.html +2 -2
  67. package/packages/web/dist/sw.js +1 -1
  68. package/packages/web/dist/assets/ProjectView-BI1TtrOK.js +0 -13
  69. package/packages/web/dist/assets/channel-DszBVZgr.js +0 -2
  70. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuGqlnbT.js +0 -2
  71. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuGqlnbT.js +0 -2
  72. package/packages/web/dist/assets/index-CvGiQddO.css +0 -1
  73. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BwcJspgG.js +0 -2
@@ -191,6 +191,24 @@ export class HerdctlService {
191
191
  * {@link cancel}). Entries are removed when the turn's stream ends.
192
192
  */
193
193
  liveSessions = new Map();
194
+ /**
195
+ * Background-phase turns keyed by the synthetic job id the sink minted for
196
+ * them, valued by the chat session they belong to (paddock#528).
197
+ *
198
+ * After a turn's primary `result` lands, {@link chatSession} returns and drops
199
+ * its {@link liveSessions} entry — but the session itself may live on, held
200
+ * open by the reaper because the turn launched background work, streaming
201
+ * autonomous re-invocation turns. Those turns are real, running, and shown as
202
+ * such; until now they were also uncancellable, because nothing registered
203
+ * them anywhere Stop could reach.
204
+ *
205
+ * `interrupt()` would be the wrong tool even if it could: it targets an
206
+ * in-flight model turn, and the wedge case is a session sitting idle holding
207
+ * background work that will never finish. So these route to a force-reap
208
+ * instead — end the session, let the stream end, and let the ordinary unwind
209
+ * emit `chat:complete`. Entries are removed when the background stream ends.
210
+ */
211
+ backgroundTurns = new Map();
194
212
  /**
195
213
  * Incremental index of `<stateDir>/jobs`, backing the unread-badge reads
196
214
  * (`lastTurnCompletedAt*`) — one per service, reused across requests so a warm
@@ -528,7 +546,29 @@ export class HerdctlService {
528
546
  : summary.sources.map((s) => s.sourceCwd);
529
547
  // sessionId -> why detection withheld it.
530
548
  const withheld = new Map(summary.filtered.map((f) => [f.sessionId, f.reason]));
549
+ // A selection narrows the offer the same way the noise filter does, and for
550
+ // the same reason: the engine adopts a whole SOURCE, so anything the user did
551
+ // not tick has to be released again afterwards. Tracked separately from
552
+ // `withheld` because a session the user simply did not choose is not a
553
+ // "skip" worth reporting back — it was never part of this import, and listing
554
+ // 23 of them would read as an import that half failed.
555
+ const deselected = opts.sessionIds === undefined
556
+ ? null
557
+ : (() => {
558
+ const wanted = new Set(opts.sessionIds);
559
+ const out = new Set();
560
+ for (const source of summary.sources) {
561
+ for (const id of source.sessionIds)
562
+ if (!wanted.has(id))
563
+ out.add(id);
564
+ }
565
+ return out;
566
+ })();
531
567
  const adopted = [];
568
+ // Of `adopted`, the ones where this call PLACED a file — the only ones undo
569
+ // may ever delete. Computed here, where `fromWorkingDir` is still known;
570
+ // afterwards nothing can tell a copy from an in-place adoption.
571
+ const copies = [];
532
572
  const skipped = [];
533
573
  for (const fromWorkingDir of sources) {
534
574
  // Sequential, not parallel: each source re-scans the adoption store, so a
@@ -542,8 +582,20 @@ export class HerdctlService {
542
582
  skipped.push(...result.skipped);
543
583
  for (const sessionId of result.adopted) {
544
584
  const reason = withheld.get(sessionId);
585
+ // Not chosen: release it, and say nothing about it. Checked BEFORE the
586
+ // noise filter so a deselected session stays silent even if it would
587
+ // also have been withheld.
588
+ if (deselected?.has(sessionId) === true) {
589
+ if (!dryRun) {
590
+ await this.releaseWithheld(agent, project, fromWorkingDir, sessionId, mode);
591
+ }
592
+ continue;
593
+ }
545
594
  if (reason === undefined) {
546
595
  adopted.push(sessionId);
596
+ if (mode === "copy" && !dryRun && this.placedACopy(project, fromWorkingDir, sessionId)) {
597
+ copies.push(sessionId);
598
+ }
547
599
  continue;
548
600
  }
549
601
  skipped.push({ sessionId, reason });
@@ -558,6 +610,8 @@ export class HerdctlService {
558
610
  this.invalidateSessions(agent);
559
611
  this.jobsIndexOrNull?.invalidate();
560
612
  this.invalidateAdoptable(project.slug);
613
+ // Replaces any earlier import: only the most recent one is undoable.
614
+ this.lastImport.set(project.slug, { adopted: [...adopted], copies });
561
615
  }
562
616
  // A session adopted from one source and then met again in the NEXT source
563
617
  // (its fresh copy now sits in the project's own transcript folder) comes back
@@ -567,6 +621,81 @@ export class HerdctlService {
567
621
  const done = new Set(adopted);
568
622
  return { adopted, skipped: skipped.filter((s) => !done.has(s.sessionId)) };
569
623
  }
624
+ /**
625
+ * Did an import of `sessionId` from `fromWorkingDir` place a NEW file?
626
+ *
627
+ * The same test `releaseWithheld` makes before deleting anything: an in-place
628
+ * adoption resolves the destination and the origin to one path, so there is no
629
+ * copy and nothing that may be removed.
630
+ */
631
+ placedACopy(project, fromWorkingDir, sessionId) {
632
+ const home = this.cfg.claudeHome;
633
+ const placed = getCliSessionFile(project.workingDir, sessionId, home);
634
+ const origin = getCliSessionFile(fromWorkingDir, sessionId, home);
635
+ return path.resolve(placed) !== path.resolve(origin);
636
+ }
637
+ /**
638
+ * What the LAST import into each workspace did, so it can be undone (#660).
639
+ *
640
+ * Keyed by workspace key (which may be `""` — the root). Holds the sessions
641
+ * that import actually brought in, and of those, the ones where it PLACED a
642
+ * copy and may therefore delete one again.
643
+ *
644
+ * Server-side rather than a token the client hands back: undo deletes files, so
645
+ * the set of paths it may touch must come from what this process actually did,
646
+ * not from a request body. A restart forgets it, which is the right failure —
647
+ * the offer to undo is a transient toast, not a durable feature.
648
+ *
649
+ * Only the most recent import per workspace is kept. Undoing an import two
650
+ * imports ago is not offered and would be a lie to describe as "undo".
651
+ */
652
+ lastImport = new Map();
653
+ /**
654
+ * Undo the import this process most recently performed into `project` (#660).
655
+ *
656
+ * Releases each adoption and deletes the copies that import placed, leaving the
657
+ * user's own `~/.claude` history exactly as it was. Returns the sessions it
658
+ * released; an empty array means there was nothing to undo (nothing imported
659
+ * yet, already undone, or the server restarted since).
660
+ *
661
+ * `sessionIds` narrows it to part of that import — the rest stays. Ids that
662
+ * were not part of it are ignored rather than rejected: undo is a best-effort
663
+ * safety net, and the alternative is a 400 for a button the user pressed once.
664
+ */
665
+ async unadoptChats(project, opts = {}) {
666
+ const agent = keeperAgentName(project.slug);
667
+ const last = this.lastImport.get(project.slug);
668
+ if (last === undefined)
669
+ return { released: [] };
670
+ const wanted = opts.sessionIds === undefined ? null : new Set(opts.sessionIds);
671
+ const target = last.adopted.filter((id) => wanted === null || wanted.has(id));
672
+ if (target.length === 0)
673
+ return { released: [] };
674
+ const copies = new Set(last.copies);
675
+ const released = [];
676
+ for (const sessionId of target) {
677
+ await this.manager.unadoptSession(agent, sessionId).catch(() => undefined);
678
+ // Delete ONLY a file this import placed. A session adopted in place has no
679
+ // copy, and its transcript is the user's own — removing it would destroy
680
+ // history rather than restore it.
681
+ if (copies.has(sessionId)) {
682
+ const placed = getCliSessionFile(project.workingDir, sessionId, this.cfg.claudeHome);
683
+ await fs.rm(placed, { force: true }).catch(() => undefined);
684
+ }
685
+ released.push(sessionId);
686
+ }
687
+ // Consume what was undone, so a second undo is a no-op rather than a repeat
688
+ // attempt to delete files that are already gone.
689
+ const remaining = last.adopted.filter((id) => !released.includes(id));
690
+ if (remaining.length === 0)
691
+ this.lastImport.delete(project.slug);
692
+ else
693
+ this.lastImport.set(project.slug, { adopted: remaining, copies: last.copies });
694
+ this.invalidateSessions(agent);
695
+ this.jobsIndexOrNull?.invalidate();
696
+ this.invalidateAdoptable(project.slug);
697
+ return { released };
698
+ }
570
699
  /**
571
700
  * Undo the adoption of a session detection had withheld, restoring the state
572
701
  * that existed before this import. See {@link adoptChats} for why the file
@@ -877,11 +1006,28 @@ export class HerdctlService {
877
1006
  }
878
1007
  return { sessionId };
879
1008
  }
1009
+ /**
1010
+ * Register a background-phase turn so Stop can reach it (paddock#528). Called
1011
+ * by the background turn sink once it knows both the synthetic job id it
1012
+ * published and the session that stream belongs to. Re-registering the same
1013
+ * job id with a later session id is fine — the sink refreshes the mapping if
1014
+ * the stream resolves a different session.
1015
+ */
1016
+ registerBackgroundTurn(jobId, sessionId) {
1017
+ this.backgroundTurns.set(jobId, sessionId);
1018
+ }
1019
+ /** Forget a background-phase turn once its stream has ended. */
1020
+ unregisterBackgroundTurn(jobId) {
1021
+ this.backgroundTurns.delete(jobId);
1022
+ }
880
1023
  /**
881
1024
  * Cancel a running turn (Stop button → WS chat:cancel). Handles BOTH drive
882
1025
  * modes off the single id the client holds as `jobId`:
883
1026
  * - **session mode** — the id is a synthetic turn id in {@link liveSessions};
884
1027
  * interrupt the live `RuntimeSession` (there is no herdctl job to cancel).
1028
+ * - **background phase** — the id is a synthetic id in
1029
+ * {@link backgroundTurns}; there is no model turn to interrupt, so reap the
1030
+ * session (paddock#528).
885
1031
  * - **batch mode** — the id is a real herdctl job id; abort it via `cancelJob`
886
1032
  * (which kills the CLI subprocess / aborts the SDK query).
887
1033
  *
@@ -901,6 +1047,19 @@ export class HerdctlService {
901
1047
  return false;
902
1048
  }
903
1049
  }
1050
+ // Background phase: the primary turn is over and its liveSessions entry is
1051
+ // gone, but the session is still open streaming autonomous re-invocation
1052
+ // turns. Stop here means "end this session" — reaping reaches even a session
1053
+ // the reap policy would hold open forever, and ending the stream is what
1054
+ // drives the sink's onDone → chat:complete → the UI unlocking.
1055
+ const backgroundSession = this.backgroundTurns.get(jobId);
1056
+ if (backgroundSession !== undefined) {
1057
+ const reaped = this.manager.reapChatSession(backgroundSession);
1058
+ if (!reaped) {
1059
+ console.warn(`[herdctl] no live managed session ${backgroundSession} to reap for background turn ${jobId}`);
1060
+ }
1061
+ return reaped;
1062
+ }
904
1063
  try {
905
1064
  await this.manager.cancelJob(jobId);
906
1065
  return true;
@@ -1311,7 +1311,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1311
1311
  schema: {
1312
1312
  tags: ["Chats"],
1313
1313
  summary: "Count the native Claude Code chats this project could import",
1314
- description: "Returns the terminal `claude` sessions that could be imported into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name for a repo-backed project, by exact path for a notebook project). Sidechains, already-imported sessions and sessions belonging to a real Paddock run are excluded by the engine; empty and slash-command-only transcripts are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an import rather than remembering a dismissal.",
1314
+ description: "Returns the terminal `claude` sessions that could be imported into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name for a repo-backed project, by exact path for a notebook project). Sidechains, already-imported sessions and sessions belonging to a real Paddock run are excluded by the engine; empty transcripts, slash-command-only transcripts and Paddock's own sweeper curation runs (`too-small` / `slash-command-only` / `sweeper-run`) are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an import rather than remembering a dismissal.",
1315
1315
  params: {
1316
1316
  type: "object",
1317
1317
  properties: {
@@ -1340,7 +1340,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1340
1340
  schema: {
1341
1341
  tags: ["Chats"],
1342
1342
  summary: "Import native Claude Code chats into this project",
1343
- description: "Imports the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is imported; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
1343
+ description: "Imports the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is imported; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. `sessionIds` narrows the import to a chosen SUBSET of what is on offer (what the confirmation dialog sends); ids that are not on offer are simply not imported, and anything the engine adopts as a side effect of taking a whole source is released again. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
1344
1344
  params: {
1345
1345
  type: "object",
1346
1346
  properties: {
@@ -1355,6 +1355,11 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1355
1355
  sourceCwd: {
1356
1356
  description: "Optional single source working directory to import from. Must be one of the `sourceCwd` values reported by GET …/adoptable-chats (or the project's own working directory); anything else is rejected with 400.",
1357
1357
  },
1358
+ sessionIds: {
1359
+ type: "array",
1360
+ items: { type: "string" },
1361
+ description: "Optional subset of the offered session ids to import. Omit to import everything on offer.",
1362
+ },
1358
1363
  },
1359
1364
  required: [],
1360
1365
  },
@@ -1382,7 +1387,10 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1382
1387
  .send({ error: `Not an importable source: ${sourceCwd}`, code: "invalid" });
1383
1388
  }
1384
1389
  }
1385
- const result = await herdctl.adoptChats(project, { sourceCwd });
1390
+ const result = await herdctl.adoptChats(project, {
1391
+ sourceCwd,
1392
+ sessionIds: req.body?.sessionIds,
1393
+ });
1386
1394
  // Stamp each imported chat's provenance so the list can badge it as
1387
1395
  // imported rather than as an ordinary human chat. `stampIfAbsent`, not
1388
1396
  // `stamp`: provenance describes how a chat came to exist and is never
@@ -1403,4 +1411,52 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1403
1411
  return sendProjectError(reply, err);
1404
1412
  }
1405
1413
  });
1414
+ app.post("/unadopt-chats", {
1415
+ schema: {
1416
+ tags: ["Chats"],
1417
+ summary: "Undo the most recent native-chat import into this project",
1418
+ description: "Reverses the import this server most recently performed into the workspace: each adoption is released and the copies that import placed are deleted, leaving the user's own `~/.claude` history untouched. `sessionIds` undoes part of that import; omit it to undo all of it. Deliberately scoped to the LAST import and remembered in memory — undo may delete files, so the paths it can touch come from what this process actually did rather than from the request body, and a restart forgets the offer rather than acting on a stale one. Response: `{ released }`, the session ids actually released; an empty array means there was nothing to undo (nothing imported, already undone, or the server restarted since).",
1419
+ params: {
1420
+ type: "object",
1421
+ properties: {
1422
+ slug: { type: "string", description: "Project slug." },
1423
+ },
1424
+ required: ["slug"],
1425
+ },
1426
+ body: {
1427
+ type: ["object", "null"],
1428
+ additionalProperties: true,
1429
+ properties: {
1430
+ sessionIds: {
1431
+ type: "array",
1432
+ items: { type: "string" },
1433
+ description: "Optional subset of the last import's session ids to release. Omit to undo the whole import. Ids outside that import are ignored.",
1434
+ },
1435
+ },
1436
+ required: [],
1437
+ },
1438
+ response: {
1439
+ 200: {
1440
+ description: "Object `{ released }` listing the session ids released.",
1441
+ type: "object",
1442
+ additionalProperties: true,
1443
+ },
1444
+ },
1445
+ },
1446
+ }, async (req, reply) => {
1447
+ try {
1448
+ const project = await projects.get(req.params.slug);
1449
+ const result = await herdctl.unadoptChats(project, {
1450
+ sessionIds: req.body?.sessionIds,
1451
+ });
1452
+ // Drop the provenance marker the import stamped, so a session that is
1453
+ // later re-imported is badged by that import rather than carrying a
1454
+ // marker from one that has been undone.
1455
+ await Promise.all(result.released.map((sessionId) => runProvenance.clear(sessionId).catch(() => undefined)));
1456
+ return result;
1457
+ }
1458
+ catch (err) {
1459
+ return sendProjectError(reply, err);
1460
+ }
1461
+ });
1406
1462
  }
@@ -104,6 +104,17 @@ export declare class RunProvenanceStore {
104
104
  * survives.
105
105
  */
106
106
  stampIfAbsent(sessionId: string, provenance: RunProvenance): Promise<void>;
107
+ /**
108
+ * Forget a chat's provenance (#660).
109
+ *
110
+ * The inverse of {@link stamp}, for the one case where a chat's creation is
111
+ * itself undone: undoing a native-chat import releases the adoption and deletes
112
+ * the copy, and a marker left behind would badge a LATER import of the same
113
+ * session as belonging to the one that was undone. Returns silently when there
114
+ * was nothing recorded, so an undo of a partially-stamped import is not an
115
+ * error.
116
+ */
117
+ clear(sessionId: string): Promise<void>;
107
118
  /** Write-through, serialised so overlapping stamps can't corrupt the file. */
108
119
  private persist;
109
120
  }
@@ -192,6 +192,24 @@ export class RunProvenanceStore {
192
192
  return;
193
193
  await this.stamp(sessionId, provenance);
194
194
  }
195
+ /**
196
+ * Forget a chat's provenance (#660).
197
+ *
198
+ * The inverse of {@link stamp}, for the one case where a chat's creation is
199
+ * itself undone: undoing a native-chat import releases the adoption and deletes
200
+ * the copy, and a marker left behind would badge a LATER import of the same
201
+ * session as belonging to the one that was undone. Returns silently when there
202
+ * was nothing recorded, so an undo of a partially-stamped import is not an
203
+ * error.
204
+ */
205
+ async clear(sessionId) {
206
+ if (!isSafeId(sessionId))
207
+ return;
208
+ const map = await this.ensureLoaded();
209
+ if (!map.delete(sessionId))
210
+ return;
211
+ await this.persist(map);
212
+ }
195
213
  /** Write-through, serialised so overlapping stamps can't corrupt the file. */
196
214
  persist(map) {
197
215
  this.writing = this.writing.then(async () => {
@@ -1,16 +1,3 @@
1
- /**
2
- * The turn-execution engine, extracted from ws.ts (#403).
3
- *
4
- * `makeTurnEngine({ deps, hub })` relocates — intact — the mutually-recursive core
5
- * that ws.ts's makeChatHandler used to hold in one closure: the trigger cluster,
6
- * the self-MCP injection context, the per-turn injected-MCP builders + wake cache,
7
- * the shared `startAgentTurn` execution engine, and the Layer-2/3 recovery
8
- * (`injectRecoveryNudge` + `RecoveryEngine`). These pieces cross-reference each
9
- * other (startAgentTurn ↔ recoveryEngine.armWatch, triggers ↔ startAgentTurn,
10
- * selfMcp injection ↔ startAgentTurn), so they must share ONE scope — moving them
11
- * as a group keeps that intact with no late-binding. ws.ts's socket layer consumes
12
- * the returned surface.
13
- */
14
1
  import type { SDKMessage, InjectedMcpServerDef } from "@herdctl/core";
15
2
  import type { Project } from "./projects.js";
16
3
  import { SessionHub } from "./session-hub.js";
@@ -1,3 +1,17 @@
1
+ /**
2
+ * The turn-execution engine, extracted from ws.ts (#403).
3
+ *
4
+ * `makeTurnEngine({ deps, hub })` relocates — intact — the mutually-recursive core
5
+ * that ws.ts's makeChatHandler used to hold in one closure: the trigger cluster,
6
+ * the self-MCP injection context, the per-turn injected-MCP builders + wake cache,
7
+ * the shared `startAgentTurn` execution engine, and the Layer-2/3 recovery
8
+ * (`injectRecoveryNudge` + `RecoveryEngine`). These pieces cross-reference each
9
+ * other (startAgentTurn ↔ recoveryEngine.armWatch, triggers ↔ startAgentTurn,
10
+ * selfMcp injection ↔ startAgentTurn), so they must share ONE scope — moving them
11
+ * as a group keeps that intact with no late-binding. ws.ts's socket layer consumes
12
+ * the returned surface.
13
+ */
14
+ import { randomUUID } from "node:crypto";
1
15
  import { createSDKMessageHandler } from "@herdctl/chat";
2
16
  import { keeperAgentName, keeperSlugFromAgent } from "./herdctl.js";
3
17
  import { isKnownDriveMode, getContextLimit } from "./models.js";
@@ -172,6 +186,13 @@ export function makeTurnEngine(engine) {
172
186
  let producedReply = false;
173
187
  let noticeEmitted = false;
174
188
  let sawError = false;
189
+ // #528: the synthetic job id published for this background stretch, and the
190
+ // session id currently registered against it. The stretch is a real running
191
+ // turn as far as the UI is concerned, so it needs a cancellable identity —
192
+ // without one `chat:active` carries `jobId: null`, the client's deferred
193
+ // cancel never fires, and Stop puts nothing on the wire at all.
194
+ let bgJobId = null;
195
+ let registeredSession = null;
175
196
  // #429: sub-agent launches recovered live from the tool_use input, keyed by
176
197
  // toolUseId, so the enriched card renders without a refresh (see subagentLaunchFields).
177
198
  const launches = new Map();
@@ -180,6 +201,19 @@ export function makeTurnEngine(engine) {
180
201
  sessionId: resolvedSession,
181
202
  jobId: turn?.jobId ?? null,
182
203
  });
204
+ /**
205
+ * Point the published job id at whatever session this stream has resolved.
206
+ * Deferred rather than done once at mint time because the id is published when
207
+ * the turn opens, which can precede the first message that names a session;
208
+ * re-run on every message so a stream that resolves (or moves to) a different
209
+ * session stays cancellable.
210
+ */
211
+ const syncBackgroundRegistration = () => {
212
+ if (!bgJobId || !resolvedSession || registeredSession === resolvedSession)
213
+ return;
214
+ deps.herdctl.registerBackgroundTurn(bgJobId, resolvedSession);
215
+ registeredSession = resolvedSession;
216
+ };
183
217
  const emitNotice = (notice) => {
184
218
  if (!turn || noticeEmitted)
185
219
  return;
@@ -196,6 +230,13 @@ export function makeTurnEngine(engine) {
196
230
  return;
197
231
  resolvedSession = sid;
198
232
  turn = hub.startTurn(projectSlug, null, sid);
233
+ // #528: give the stretch a cancellable identity the moment it opens, exactly
234
+ // as the foreground path does via onJobCreated. Stop then routes to a
235
+ // force-reap of this session rather than an interrupt — there is no model
236
+ // turn in flight here to interrupt.
237
+ bgJobId = randomUUID();
238
+ turn.setJobId(bgJobId);
239
+ syncBackgroundRegistration();
199
240
  const t = turn;
200
241
  translate = createSDKMessageHandler({
201
242
  onText: (chunk) => {
@@ -252,6 +293,7 @@ export function makeTurnEngine(engine) {
252
293
  ensureTurn(m.session_id ?? resolvedSession);
253
294
  if (m.session_id)
254
295
  turn.setSession(m.session_id);
296
+ syncBackgroundRegistration();
255
297
  if (messageProducedReply(m))
256
298
  producedReply = true;
257
299
  const notice = noticeFromMessage(m);
@@ -266,6 +308,14 @@ export function makeTurnEngine(engine) {
266
308
  // Finalize the single turn once the background stream ends (reaper reap). No-op
267
309
  // if nothing was ever rendered (a sidechain-only stretch opened no turn).
268
310
  const onDone = () => {
311
+ // Drop the cancellable identity first, and unconditionally: the stream is
312
+ // over, so a later Stop must fall through to the "already finished" path
313
+ // rather than reaping whatever session has since taken this id. Runs even
314
+ // for a sidechain-only stretch that opened no turn, and on a second call.
315
+ if (bgJobId)
316
+ deps.herdctl.unregisterBackgroundTurn(bgJobId);
317
+ bgJobId = null;
318
+ registeredSession = null;
269
319
  if (!turn)
270
320
  return;
271
321
  turn.emit({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paddock/server",
3
- "version": "0.59.1",
3
+ "version": "0.60.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,2 +1,2 @@
1
- import{r as o,b as f,j as e,A as b,k as E,l as A,m as O,M as F,n as I}from"./index-DKDgTDXa.js";function $(){const[t,s]=o.useState(null),[l,i]=o.useState(!0),[r,c]=o.useState(null),[d,m]=o.useState({}),[h,k]=o.useState(!1),[j,p]=o.useState(null),[N,u]=o.useState(!1);o.useEffect(()=>{let a=!0;return i(!0),f.getInstanceConfig().then(n=>{a&&(s(n),c(null))}).catch(n=>a&&c(n instanceof Error?n.message:String(n))).finally(()=>a&&i(!1)),()=>{a=!1}},[]);const v=(a,n)=>{u(!1),p(null),m(g=>({...g,[a]:n}))},S=a=>Object.prototype.hasOwnProperty.call(d,a.key)?d[a.key]:a.value,y=o.useMemo(()=>t?t.groups.flatMap(a=>a.fields):[],[t]),x=o.useMemo(()=>y.filter(a=>a.editable&&!a.envOverridden).filter(a=>Object.prototype.hasOwnProperty.call(d,a.key)).filter(a=>!B(d[a.key],a.value)).map(a=>a.key),[y,d]),w=async()=>{if(x.length===0)return;const a={};for(const n of x)a[n]=d[n];k(!0),p(null);try{await f.updateInstanceConfig(a);const n=await f.getInstanceConfig();s(n),m({}),u(!0)}catch(n){p(n instanceof A||n instanceof Error?n.message:String(n))}finally{k(!1)}},C=()=>{m({}),p(null),u(!1)};return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6",children:e.jsxs("div",{className:"mx-auto max-w-2xl",children:[e.jsx(L,{saved:N,configPath:t==null?void 0:t.configPath}),l&&e.jsx("p",{className:"text-sm text-paddock-500",children:"Loading…"}),r&&e.jsxs("p",{className:"text-sm text-red-600 dark:text-red-400",role:"alert",children:["Failed to load settings: ",r]}),t&&t.groups.map(a=>e.jsx(M,{title:a.label,description:a.description,children:e.jsx("div",{className:"grid grid-cols-1 gap-4",children:a.fields.map(n=>e.jsx(P,{field:n,value:S(n),onChange:g=>v(n.key,g)},n.key))})},a.id))]})}),t&&e.jsxs("footer",{className:"flex items-center gap-3 border-t border-paddock-200 px-4 py-3 dark:border-paddock-800 sm:px-6",children:[j&&e.jsxs("span",{className:"flex items-center gap-1.5 text-[13px] text-red-600 dark:text-red-400",role:"alert",children:[e.jsx(b,{width:14,height:14,className:"shrink-0"}),j]}),e.jsx("span",{className:"ml-auto text-[12px] text-paddock-400",children:x.length>0?`${x.length} unsaved change${x.length===1?"":"s"}`:"No changes"}),e.jsx("button",{type:"button",className:"btn-subtle",onClick:C,disabled:x.length===0||h,children:"Reset"}),e.jsx("button",{type:"button",className:"btn-primary",onClick:w,disabled:x.length===0||h,children:h?"Saving…":"Save changes"})]})]})}function L({saved:t,configPath:s}){return e.jsxs("div",{role:"status",className:`mb-6 flex items-start gap-2 rounded-lg border px-3 py-2.5 text-[13px] leading-snug ${t?"border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-700/60 dark:bg-emerald-900/20 dark:text-emerald-300":"border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-300"}`,children:[t?e.jsx(E,{width:15,height:15,className:"mt-0.5 shrink-0"}):e.jsx(b,{width:15,height:15,className:"mt-0.5 shrink-0"}),e.jsx("span",{children:t?e.jsxs(e.Fragment,{children:[e.jsx("strong",{children:"Saved to disk."})," These changes take effect only after the server restarts."]}):e.jsxs(e.Fragment,{children:["Changes here are written to ",e.jsx("code",{className:"font-mono text-[12px]",children:T(s)})," ","and take effect only after the server restarts — the running instance keeps its current config until then."]})})]})}function M({title:t,description:s,children:l}){return e.jsxs("section",{className:"mb-6",children:[e.jsx("h2",{className:"text-sm font-semibold uppercase tracking-wide text-paddock-500",children:t}),s&&e.jsx("p",{className:"mb-3 mt-0.5 text-[13px] text-paddock-500",children:s}),e.jsx("div",{className:`card ${s?"":"mt-2"}`,children:l})]})}function P({field:t,value:s,onChange:l}){const i=!t.editable||t.envOverridden,r=`cfg-${t.key}`;return e.jsxs("div",{children:[e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("label",{htmlFor:i?void 0:r,className:"text-sm font-medium",children:[t.label,t.sensitive&&e.jsx("span",{className:"ml-1.5 align-middle text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400",children:"sensitive"})]}),t.type==="boolean"&&!i&&e.jsx("input",{id:r,type:"checkbox",className:"h-4 w-4 accent-accent",checked:!!s,onChange:c=>l(c.target.checked)})]}),t.help&&e.jsx("p",{className:"mt-0.5 text-[12px] leading-snug text-paddock-500",children:t.help}),i?e.jsx(V,{field:t,value:s}):t.type!=="boolean"&&e.jsx(R,{field:t,value:s,onChange:l,inputId:r}),t.envOverridden&&e.jsxs("p",{className:"mt-1 flex items-start gap-1.5 text-[12px] leading-snug text-amber-600 dark:text-amber-400",children:[e.jsx(b,{width:13,height:13,className:"mt-0.5 shrink-0"}),e.jsxs("span",{children:["Overridden by environment variable ",e.jsx("code",{className:"font-mono",children:t.envVar})," — edit that env var (and restart) to change it."]})]})]})}function R({field:t,value:s,onChange:l,inputId:i}){if(t.type==="enum")return e.jsx("select",{id:i,className:"input mt-1.5",value:String(s??""),onChange:r=>l(r.target.value),children:(t.enumValues??[]).map(r=>e.jsx("option",{value:r,children:r},r))});if(t.type==="number")return e.jsx("input",{id:i,type:"number",className:"input mt-1.5",value:s==null?"":String(s),placeholder:t.default===null?"default":String(t.default),onChange:r=>l(r.target.value===""?null:Number(r.target.value))});if(t.type==="text"){const r=s==null||s===t.default,c=s==null?String(t.default??""):String(s),d=typeof t.default=="string"&&!r;return e.jsxs("div",{className:"mt-1.5",children:[e.jsx("textarea",{id:i,className:"input min-h-[9rem] resize-y font-mono text-[12px] leading-relaxed",rows:10,spellCheck:!1,value:c,onChange:m=>l(m.target.value)}),e.jsxs("div",{className:"mt-1 flex items-center justify-between gap-3 text-[12px] text-paddock-500",children:[e.jsx("span",{children:r?"Using the built-in default.":s===""?"Empty — nothing will be appended.":`${c.length.toLocaleString()} characters.`}),d&&e.jsx("button",{type:"button",className:"shrink-0 underline underline-offset-2 hover:text-paddock-700 dark:hover:text-paddock-300",onClick:()=>l(null),children:"Restore default"})]})]})}if(t.type==="string-list"){const r=Array.isArray(s)?s.join(", "):String(s??"");return e.jsx("input",{id:i,type:"text",className:"input mt-1.5",value:r,onChange:c=>l(c.target.value.split(",").map(d=>d.trim()).filter(Boolean))})}return e.jsx("input",{id:i,type:"text",className:"input mt-1.5",value:s==null?"":String(s),placeholder:t.default===null?"":String(t.default),onChange:r=>l(r.target.value)})}function V({field:t,value:s}){return e.jsx("div",{className:"mt-1.5 rounded-md border border-dashed border-paddock-300 bg-paddock-50 px-2.5 py-1.5 text-[13px] text-paddock-600 dark:border-paddock-700 dark:bg-paddock-900/40 dark:text-paddock-300",children:t.type==="boolean"?e.jsx("span",{className:"font-mono",children:s?"true":"false"}):s==null||s===""?e.jsx("span",{className:"italic text-paddock-400",children:t.type==="text"&&s===""?"(empty — nothing appended)":"(not set)"}):t.type==="text"?e.jsx("span",{className:"block max-h-64 overflow-y-auto whitespace-pre-wrap font-mono text-[12px] leading-relaxed",children:String(s)}):e.jsx("span",{className:"break-all font-mono",children:Array.isArray(s)?s.join(", "):String(s)})})}function B(t,s){return Array.isArray(t)&&Array.isArray(s)?t.length===s.length&&t.every((l,i)=>l===s[i]):t===s}function T(t){if(!t)return"paddock.config.yaml";const s=t.split("/");return s[s.length-1]||t}function z(){const t=O(),s=(t==null?void 0:t.openNav)??(()=>{});return e.jsxs("div",{className:"flex h-full min-h-0 flex-col",children:[e.jsxs("header",{className:"pt-safe flex items-center gap-2 border-b border-paddock-200 px-3 pb-2.5 dark:border-paddock-800 sm:px-6 lg:py-4",children:[e.jsx("button",{type:"button",onClick:s,"aria-label":"Open menu",className:"btn-subtle -ml-1 shrink-0 px-2 py-1.5 lg:hidden",children:e.jsx(F,{width:20,height:20})}),e.jsx(I,{width:18,height:18,className:"shrink-0 text-paddock-400"}),e.jsx("h1",{className:"text-[15px] font-semibold tracking-tight",children:"Config"})]}),e.jsx($,{})]})}export{z as InstanceConfigPage};
1
+ import{r as o,b as f,j as e,A as b,k as E,l as A,m as O,M as F,n as I}from"./index-DtRblEY_.js";function $(){const[t,s]=o.useState(null),[l,i]=o.useState(!0),[r,c]=o.useState(null),[d,m]=o.useState({}),[h,k]=o.useState(!1),[j,p]=o.useState(null),[N,u]=o.useState(!1);o.useEffect(()=>{let a=!0;return i(!0),f.getInstanceConfig().then(n=>{a&&(s(n),c(null))}).catch(n=>a&&c(n instanceof Error?n.message:String(n))).finally(()=>a&&i(!1)),()=>{a=!1}},[]);const v=(a,n)=>{u(!1),p(null),m(g=>({...g,[a]:n}))},S=a=>Object.prototype.hasOwnProperty.call(d,a.key)?d[a.key]:a.value,y=o.useMemo(()=>t?t.groups.flatMap(a=>a.fields):[],[t]),x=o.useMemo(()=>y.filter(a=>a.editable&&!a.envOverridden).filter(a=>Object.prototype.hasOwnProperty.call(d,a.key)).filter(a=>!B(d[a.key],a.value)).map(a=>a.key),[y,d]),w=async()=>{if(x.length===0)return;const a={};for(const n of x)a[n]=d[n];k(!0),p(null);try{await f.updateInstanceConfig(a);const n=await f.getInstanceConfig();s(n),m({}),u(!0)}catch(n){p(n instanceof A||n instanceof Error?n.message:String(n))}finally{k(!1)}},C=()=>{m({}),p(null),u(!1)};return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6",children:e.jsxs("div",{className:"mx-auto max-w-2xl",children:[e.jsx(L,{saved:N,configPath:t==null?void 0:t.configPath}),l&&e.jsx("p",{className:"text-sm text-paddock-500",children:"Loading…"}),r&&e.jsxs("p",{className:"text-sm text-red-600 dark:text-red-400",role:"alert",children:["Failed to load settings: ",r]}),t&&t.groups.map(a=>e.jsx(M,{title:a.label,description:a.description,children:e.jsx("div",{className:"grid grid-cols-1 gap-4",children:a.fields.map(n=>e.jsx(P,{field:n,value:S(n),onChange:g=>v(n.key,g)},n.key))})},a.id))]})}),t&&e.jsxs("footer",{className:"flex items-center gap-3 border-t border-paddock-200 px-4 py-3 dark:border-paddock-800 sm:px-6",children:[j&&e.jsxs("span",{className:"flex items-center gap-1.5 text-[13px] text-red-600 dark:text-red-400",role:"alert",children:[e.jsx(b,{width:14,height:14,className:"shrink-0"}),j]}),e.jsx("span",{className:"ml-auto text-[12px] text-paddock-400",children:x.length>0?`${x.length} unsaved change${x.length===1?"":"s"}`:"No changes"}),e.jsx("button",{type:"button",className:"btn-subtle",onClick:C,disabled:x.length===0||h,children:"Reset"}),e.jsx("button",{type:"button",className:"btn-primary",onClick:w,disabled:x.length===0||h,children:h?"Saving…":"Save changes"})]})]})}function L({saved:t,configPath:s}){return e.jsxs("div",{role:"status",className:`mb-6 flex items-start gap-2 rounded-lg border px-3 py-2.5 text-[13px] leading-snug ${t?"border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-700/60 dark:bg-emerald-900/20 dark:text-emerald-300":"border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-300"}`,children:[t?e.jsx(E,{width:15,height:15,className:"mt-0.5 shrink-0"}):e.jsx(b,{width:15,height:15,className:"mt-0.5 shrink-0"}),e.jsx("span",{children:t?e.jsxs(e.Fragment,{children:[e.jsx("strong",{children:"Saved to disk."})," These changes take effect only after the server restarts."]}):e.jsxs(e.Fragment,{children:["Changes here are written to ",e.jsx("code",{className:"font-mono text-[12px]",children:T(s)})," ","and take effect only after the server restarts — the running instance keeps its current config until then."]})})]})}function M({title:t,description:s,children:l}){return e.jsxs("section",{className:"mb-6",children:[e.jsx("h2",{className:"text-sm font-semibold uppercase tracking-wide text-paddock-500",children:t}),s&&e.jsx("p",{className:"mb-3 mt-0.5 text-[13px] text-paddock-500",children:s}),e.jsx("div",{className:`card ${s?"":"mt-2"}`,children:l})]})}function P({field:t,value:s,onChange:l}){const i=!t.editable||t.envOverridden,r=`cfg-${t.key}`;return e.jsxs("div",{children:[e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("label",{htmlFor:i?void 0:r,className:"text-sm font-medium",children:[t.label,t.sensitive&&e.jsx("span",{className:"ml-1.5 align-middle text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400",children:"sensitive"})]}),t.type==="boolean"&&!i&&e.jsx("input",{id:r,type:"checkbox",className:"h-4 w-4 accent-accent",checked:!!s,onChange:c=>l(c.target.checked)})]}),t.help&&e.jsx("p",{className:"mt-0.5 text-[12px] leading-snug text-paddock-500",children:t.help}),i?e.jsx(V,{field:t,value:s}):t.type!=="boolean"&&e.jsx(R,{field:t,value:s,onChange:l,inputId:r}),t.envOverridden&&e.jsxs("p",{className:"mt-1 flex items-start gap-1.5 text-[12px] leading-snug text-amber-600 dark:text-amber-400",children:[e.jsx(b,{width:13,height:13,className:"mt-0.5 shrink-0"}),e.jsxs("span",{children:["Overridden by environment variable ",e.jsx("code",{className:"font-mono",children:t.envVar})," — edit that env var (and restart) to change it."]})]})]})}function R({field:t,value:s,onChange:l,inputId:i}){if(t.type==="enum")return e.jsx("select",{id:i,className:"input mt-1.5",value:String(s??""),onChange:r=>l(r.target.value),children:(t.enumValues??[]).map(r=>e.jsx("option",{value:r,children:r},r))});if(t.type==="number")return e.jsx("input",{id:i,type:"number",className:"input mt-1.5",value:s==null?"":String(s),placeholder:t.default===null?"default":String(t.default),onChange:r=>l(r.target.value===""?null:Number(r.target.value))});if(t.type==="text"){const r=s==null||s===t.default,c=s==null?String(t.default??""):String(s),d=typeof t.default=="string"&&!r;return e.jsxs("div",{className:"mt-1.5",children:[e.jsx("textarea",{id:i,className:"input min-h-[9rem] resize-y font-mono text-[12px] leading-relaxed",rows:10,spellCheck:!1,value:c,onChange:m=>l(m.target.value)}),e.jsxs("div",{className:"mt-1 flex items-center justify-between gap-3 text-[12px] text-paddock-500",children:[e.jsx("span",{children:r?"Using the built-in default.":s===""?"Empty — nothing will be appended.":`${c.length.toLocaleString()} characters.`}),d&&e.jsx("button",{type:"button",className:"shrink-0 underline underline-offset-2 hover:text-paddock-700 dark:hover:text-paddock-300",onClick:()=>l(null),children:"Restore default"})]})]})}if(t.type==="string-list"){const r=Array.isArray(s)?s.join(", "):String(s??"");return e.jsx("input",{id:i,type:"text",className:"input mt-1.5",value:r,onChange:c=>l(c.target.value.split(",").map(d=>d.trim()).filter(Boolean))})}return e.jsx("input",{id:i,type:"text",className:"input mt-1.5",value:s==null?"":String(s),placeholder:t.default===null?"":String(t.default),onChange:r=>l(r.target.value)})}function V({field:t,value:s}){return e.jsx("div",{className:"mt-1.5 rounded-md border border-dashed border-paddock-300 bg-paddock-50 px-2.5 py-1.5 text-[13px] text-paddock-600 dark:border-paddock-700 dark:bg-paddock-900/40 dark:text-paddock-300",children:t.type==="boolean"?e.jsx("span",{className:"font-mono",children:s?"true":"false"}):s==null||s===""?e.jsx("span",{className:"italic text-paddock-400",children:t.type==="text"&&s===""?"(empty — nothing appended)":"(not set)"}):t.type==="text"?e.jsx("span",{className:"block max-h-64 overflow-y-auto whitespace-pre-wrap font-mono text-[12px] leading-relaxed",children:String(s)}):e.jsx("span",{className:"break-all font-mono",children:Array.isArray(s)?s.join(", "):String(s)})})}function B(t,s){return Array.isArray(t)&&Array.isArray(s)?t.length===s.length&&t.every((l,i)=>l===s[i]):t===s}function T(t){if(!t)return"paddock.config.yaml";const s=t.split("/");return s[s.length-1]||t}function z(){const t=O(),s=(t==null?void 0:t.openNav)??(()=>{});return e.jsxs("div",{className:"flex h-full min-h-0 flex-col",children:[e.jsxs("header",{className:"pt-safe flex items-center gap-2 border-b border-paddock-200 px-3 pb-2.5 dark:border-paddock-800 sm:px-6 lg:py-4",children:[e.jsx("button",{type:"button",onClick:s,"aria-label":"Open menu",className:"btn-subtle -ml-1 shrink-0 px-2 py-1.5 lg:hidden",children:e.jsx(F,{width:20,height:20})}),e.jsx(I,{width:18,height:18,className:"shrink-0 text-paddock-400"}),e.jsx("h1",{className:"text-[15px] font-semibold tracking-tight",children:"Config"})]}),e.jsx($,{})]})}export{z as InstanceConfigPage};
2
2