@edspencer/paddock 0.70.1 → 0.71.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 (99) hide show
  1. package/package.json +2 -2
  2. package/packages/server/dist/background-live.d.ts +24 -0
  3. package/packages/server/dist/background-live.js +38 -0
  4. package/packages/server/dist/chat-dto.d.ts +3 -2
  5. package/packages/server/dist/chat-dto.js +9 -1
  6. package/packages/server/dist/cli/args.d.ts +11 -3
  7. package/packages/server/dist/cli/args.js +33 -7
  8. package/packages/server/dist/cli/paddock.js +3 -3
  9. package/packages/server/dist/cli/service/index.d.ts +10 -1
  10. package/packages/server/dist/cli/service/index.js +55 -6
  11. package/packages/server/dist/cli/service/ready.d.ts +61 -0
  12. package/packages/server/dist/cli/service/ready.js +71 -0
  13. package/packages/server/dist/discover.d.ts +37 -0
  14. package/packages/server/dist/discover.js +51 -0
  15. package/packages/server/dist/herdctl.d.ts +51 -1
  16. package/packages/server/dist/herdctl.js +53 -6
  17. package/packages/server/dist/last-activity.d.ts +34 -0
  18. package/packages/server/dist/last-activity.js +178 -0
  19. package/packages/server/dist/management-ops.js +5 -1
  20. package/packages/server/dist/routes/chats.js +7 -7
  21. package/packages/server/dist/routes/discover.js +10 -3
  22. package/packages/server/dist/self-mcp-types.d.ts +4 -1
  23. package/packages/server/dist/transcripts.js +5 -3
  24. package/packages/server/dist/ws-protocol.d.ts +78 -2
  25. package/packages/server/dist/ws-protocol.js +9 -0
  26. package/packages/server/dist/ws.js +38 -0
  27. package/packages/server/package.json +1 -1
  28. package/packages/web/dist/assets/Chip-Db--PJFH.js +2 -0
  29. package/packages/web/dist/assets/DiscoverPage-BFo4FTzZ.js +2 -0
  30. package/packages/web/dist/assets/DiscoverView-C4hOB3nr.js +2 -0
  31. package/packages/web/dist/assets/InstanceConfigPage-U78M95nW.js +2 -0
  32. package/packages/web/dist/assets/{MarkdownRenderer-DatKu1Pl.js → MarkdownRenderer-DdoNl0Ao.js} +2 -2
  33. package/packages/web/dist/assets/{ProjectMenu-DJB7uCGt.js → ProjectMenu-BxQu14ze.js} +1 -1
  34. package/packages/web/dist/assets/{ProjectRedirect-D5Uxki7M.js → ProjectRedirect-QuGeN-qx.js} +1 -1
  35. package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +13 -0
  36. package/packages/web/dist/assets/{ProjectsGrid-CqQTeqdE.js → ProjectsGrid-CUFNVTif.js} +1 -1
  37. package/packages/web/dist/assets/RootHome-SIxo-xX9.js +2 -0
  38. package/packages/web/dist/assets/{arc-Bvnrokeb.js → arc-C0DRwCCT.js} +1 -1
  39. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc5OXCmK.js → architectureDiagram-3BPJPVTR-Wwpf4cvW.js} +1 -1
  40. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-CFZeUqSH.js → blockDiagram-GPEHLZMM-DKmCbgWk.js} +1 -1
  41. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BPI5UmpX.js → c4Diagram-AAUBKEIU-CJ8LiLsj.js} +1 -1
  42. package/packages/web/dist/assets/channel-DEmtNKeB.js +2 -0
  43. package/packages/web/dist/assets/{chunk-2J33WTMH-CVpthvPT.js → chunk-2J33WTMH-C3P-O4C3.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-4BX2VUAB-cTaBnmzz.js → chunk-4BX2VUAB-Cs9u7x61.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-55IACEB6-dkgF9sAS.js → chunk-55IACEB6-C1st284U.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-727SXJPM-UiFZch2W.js → chunk-727SXJPM-BjCAWo7B.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-AQP2D5EJ-IKsoQNZy.js → chunk-AQP2D5EJ-BP6S9w2Q.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-FMBD7UC4-Tzt_HckZ.js → chunk-FMBD7UC4-C3ySTaPs.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-ND2GUHAM-dbNwUFdT.js → chunk-ND2GUHAM-eNPD4vzc.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-QZHKN3VN-eUsG1OkP.js → chunk-QZHKN3VN-BbBpgHkO.js} +1 -1
  51. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-dAGAuccL.js +2 -0
  52. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-dAGAuccL.js +2 -0
  53. package/packages/web/dist/assets/{core-Duun4AtS.js → core-ZwDDec0y.js} +1 -1
  54. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-KF_gNdsd.js → cose-bilkent-S5V4N54A-DiM4eeF5.js} +1 -1
  55. package/packages/web/dist/assets/{dagre-BM42HDAG-CXFDGeP7.js → dagre-BM42HDAG-DKIEUM4M.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-2AECGRRQ-BiefFjo3.js → diagram-2AECGRRQ-CqlX7j8t.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-5GNKFQAL-B1IpANC8.js → diagram-5GNKFQAL-Dph5apqo.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-KO2AKTUF-ChdOA3EK.js → diagram-KO2AKTUF-TW6LyHGt.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-LMA3HP47-BTbLDsXf.js → diagram-LMA3HP47-BxxIIv5a.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-OG6HWLK6-Bqiqt27L.js → diagram-OG6HWLK6-B8M0DeV7.js} +1 -1
  61. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-KA9WLkV0.js → erDiagram-TEJ5UH35-Bfpy6UQa.js} +1 -1
  62. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CZKvoeTz.js → flowDiagram-I6XJVG4X-DstRzxek.js} +1 -1
  63. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-o1QDzlgt.js → ganttDiagram-6RSMTGT7-Bg0oFbAl.js} +1 -1
  64. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-D5GdOppF.js → gitGraphDiagram-PVQCEYII-CX4IozE-.js} +1 -1
  65. package/packages/web/dist/assets/{index-B-J2K1w2.js → index-ASW1Jl0Y.js} +11 -11
  66. package/packages/web/dist/assets/index-bC5uCujx.css +1 -0
  67. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-21Z9hMO-.js → infoDiagram-5YYISTIA-CG2qCk6u.js} +1 -1
  68. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-TmbPK40i.js → ishikawaDiagram-YF4QCWOH-CPErvl3a.js} +1 -1
  69. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DYAWKu-K.js → journeyDiagram-JHISSGLW-Bm6krUsf.js} +1 -1
  70. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-ZW_ZQ0DQ.js → kanban-definition-UN3LZRKU-B4CC8CAx.js} +1 -1
  71. package/packages/web/dist/assets/{linear-BDQKnwHn.js → linear-CJ1DbrOX.js} +1 -1
  72. package/packages/web/dist/assets/{mermaid.core-BHXnmT7N.js → mermaid.core-Dk2kkhf9.js} +4 -4
  73. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CHVPYLQs.js → mindmap-definition-RKZ34NQL-CThvf_YD.js} +1 -1
  74. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-paqmu057.js → pieDiagram-4H26LBE5-DvkHcng6.js} +1 -1
  75. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-diM_4CGS.js → quadrantDiagram-W4KKPZXB-CqhrTh9q.js} +1 -1
  76. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DCyNbyIz.js → requirementDiagram-4Y6WPE33-CZykiLym.js} +1 -1
  77. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DxpEvGOy.js → sankeyDiagram-5OEKKPKP-D-TzCKMx.js} +1 -1
  78. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-BH4-7BdR.js → sequenceDiagram-3UESZ5HK-C5pIItVu.js} +1 -1
  79. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CVPrwckU.js → stateDiagram-AJRCARHV-DE6G2JfB.js} +1 -1
  80. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BP6oowh1.js +2 -0
  81. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-B6_yfyHl.js → timeline-definition-PNZ67QCA-C1VK2OfU.js} +1 -1
  82. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Ic8OogjJ.js → vennDiagram-CIIHVFJN-CtOozEwa.js} +1 -1
  83. package/packages/web/dist/assets/{wardley-L42UT6IY-CuhgMTiv.js → wardley-L42UT6IY-DvHGK-lb.js} +1 -1
  84. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Djeda9cE.js → wardleyDiagram-YWT4CUSO-DxKwZ3-G.js} +1 -1
  85. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D3hl8Mdh.js → xychartDiagram-2RQKCTM6-HM4j3l6y.js} +1 -1
  86. package/packages/web/dist/index.html +2 -2
  87. package/packages/web/dist/sw.js +1 -1
  88. package/packages/web/dist/assets/Chip-P4n88XYj.js +0 -2
  89. package/packages/web/dist/assets/DiscoverPage-B7ap1I1Y.js +0 -2
  90. package/packages/web/dist/assets/DiscoverView-DxC3mmGp.js +0 -2
  91. package/packages/web/dist/assets/InstanceConfigPage-DO87_86Z.js +0 -2
  92. package/packages/web/dist/assets/ProjectView-D8TqrBW4.js +0 -13
  93. package/packages/web/dist/assets/RootHome-B0si7ijq.js +0 -2
  94. package/packages/web/dist/assets/Surfaces-t3XF_my5.js +0 -2
  95. package/packages/web/dist/assets/channel-BGIf4GoQ.js +0 -2
  96. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-6kluxBAR.js +0 -2
  97. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-6kluxBAR.js +0 -2
  98. package/packages/web/dist/assets/index-4ugpmAz4.css +0 -1
  99. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CMmy9TKm.js +0 -2
@@ -61,6 +61,18 @@
61
61
  *
62
62
  * Surviving directories are ranked by non-noise session count, then by recency.
63
63
  *
64
+ * ## Paddock's own folders never enter the scan at all (#865)
65
+ *
66
+ * {@link withoutOwnTranscriptFolders} runs BEFORE any of this, and it is not one
67
+ * more exclusion rule: a folder it drops is not counted in `scanned` either.
68
+ * That distinction is the whole point. Paddock plants a symlink into its Claude
69
+ * home for each workspace's `.chats` at boot, so a machine that has never run
70
+ * Claude Code reported `scanned: 2, excluded: {"no-recorded-cwd": 2}` — and
71
+ * `scanned === 0`, the one signal that separates "no history here" from "all of
72
+ * it was filtered out", was therefore unreachable in the case it was written
73
+ * for. The UI then told a first-time user their history was "already a project,
74
+ * or was filtered out", on a machine with no history at all.
75
+ *
64
76
  * ## Why counts are reported rather than dropped
65
77
  *
66
78
  * `DiscoverResult.excluded` carries how many directories each rule ate. Without it a
@@ -96,6 +108,45 @@ const TEMP_PATH_ROOTS = [
96
108
  export function defaultTempRoots() {
97
109
  return [...TEMP_PATH_ROOTS, os.tmpdir()];
98
110
  }
111
+ /**
112
+ * Drop the transcript folders paddock planted itself, BEFORE anything is counted
113
+ * (issue #865).
114
+ *
115
+ * Paddock bridges each workspace's `.chats` into its Claude home as a symlink at
116
+ * boot — the root workspace's and the sweeper's exist on an instance nobody has
117
+ * touched yet. They are transcript folders by every structural test, so the scan
118
+ * saw two of them, excluded both as `no-recorded-cwd` (they are empty), and
119
+ * reported `scanned: 2`. Reporting them as *scanned* is the bug: `scanned === 0`
120
+ * is what the UI keys "no Claude Code history on this machine" off, and paddock's
121
+ * own bookkeeping is not the user's history.
122
+ *
123
+ * ## The test, and why it is this one
124
+ *
125
+ * A folder is paddock's own when it resolves INTO paddock's own storage
126
+ * (`ownRoots`: the projects root and the data dir) while resolving OUTSIDE every
127
+ * Claude home. Both halves are load-bearing:
128
+ *
129
+ * - the second half alone would drop everything — a real folder physically
130
+ * lives in `<claudeHome>/projects/`, which is itself inside the data dir;
131
+ * - the first half alone would drop the user's OWN mirrored folders (#620),
132
+ * which resolve into `~/.claude` — inside `legacyClaudeHome`, and exactly the
133
+ * history this feature exists to offer.
134
+ *
135
+ * So: resolves into a claude home ⇒ the user's, keep. Resolves into paddock's
136
+ * storage but no claude home ⇒ a `.chats` bridge paddock made, drop.
137
+ *
138
+ * Note this is deliberately NOT another {@link DiscoverExclusion}. An exclusion
139
+ * is a directory the user has that we are declining to offer, and it is counted
140
+ * and shown as such; these are folders that were never theirs to begin with.
141
+ */
142
+ export function withoutOwnTranscriptFolders(folders, roots) {
143
+ return folders.filter((folder) => {
144
+ const real = folder.realPath;
145
+ if (roots.claudeHomes.some((home) => isPathInside(real, home)))
146
+ return true;
147
+ return !roots.ownRoots.some((root) => isPathInside(real, root));
148
+ });
149
+ }
99
150
  /**
100
151
  * Is this an ephemeral scratch directory?
101
152
  *
@@ -53,6 +53,7 @@ import { type PaddockTrigger } from "./trigger-config.js";
53
53
  import { type McpSources } from "./claude-mcp.js";
54
54
  import { type HostPluginSource } from "./claude-plugins.js";
55
55
  import { type AdoptableSummary, type FilterReason, type TranscriptFolder } from "./adoptable.js";
56
+ import { type SessionWithActivity } from "./last-activity.js";
56
57
  /**
57
58
  * One session an import did NOT bring in, with a reason a UI can show verbatim.
58
59
  *
@@ -79,6 +80,26 @@ export interface ChatDeletion {
79
80
  /** The transcript was deliberately left on disk because it is not paddock's. */
80
81
  retained: boolean;
81
82
  }
83
+ /**
84
+ * What happened to a request to stop one background task (#848).
85
+ *
86
+ * Three cases, kept apart on purpose — see
87
+ * {@link HerdctlService.stopBackgroundTask} for why only `error` is a failure.
88
+ */
89
+ export type StopTaskOutcome =
90
+ /** Accepted. The SDK's own terminal notification is what removes the row. */
91
+ {
92
+ outcome: "stopping";
93
+ }
94
+ /** No live session — the work died with it. The caller drops the row. */
95
+ | {
96
+ outcome: "gone";
97
+ }
98
+ /** The stop did not happen and the task is still running. */
99
+ | {
100
+ outcome: "error";
101
+ message: string;
102
+ };
82
103
  /** Options passed through to a streamed trigger. */
83
104
  export interface ChatTurnOptions {
84
105
  prompt: string;
@@ -768,6 +789,35 @@ export declare class HerdctlService {
768
789
  * failure, so a broken Stop looked like a no-op).
769
790
  */
770
791
  cancel(jobId: string): Promise<boolean>;
792
+ /**
793
+ * Stop ONE background task, by session id and SDK task id (issue #848).
794
+ *
795
+ * Deliberately keyed on the SESSION, not on {@link liveSessions}. That map is
796
+ * emptied in a `finally` the moment the primary turn's result lands, while the
797
+ * background tasks it launched keep running — so a `liveSessions`-keyed stop
798
+ * would go inert during exactly the phase the running-work bar exists for.
799
+ * `stopTaskInSession` is herdctl's answer to that gap: the reaper holds the
800
+ * session across it.
801
+ *
802
+ * There is deliberately NO liveness pre-check. A reap landing between render
803
+ * and click is a routine race, and `false` already describes it precisely.
804
+ *
805
+ * The three outcomes are kept DISTINCT all the way to the UI, because they
806
+ * mean genuinely different things and only one of them is a failure:
807
+ *
808
+ * - `stopping` — the request was accepted. Nothing is evicted here: the SDK
809
+ * emits the terminal `task_notification{status:"stopped"}` on the session's
810
+ * own stream and {@link BackgroundRegistry} already evicts on it. This is
811
+ * also the idempotent case — the CLI answers `not_found` / `not_running`
812
+ * with a success, so a click racing a natural completion lands here.
813
+ * - `gone` — no live session with that id. NOT a failure: the tasks died with
814
+ * the session, so the user's intent is already satisfied. The caller drops
815
+ * the row itself, because there is no stream left to notify on.
816
+ * - `error` — the stop did NOT happen and the task is still running. Notably
817
+ * a `monitor_mcp` task, which the CLI has no kill strategy for and rejects
818
+ * with `unsupported_type`. Surfaced rather than reported as success.
819
+ */
820
+ stopBackgroundTask(sessionId: string, taskId: string): Promise<StopTaskOutcome>;
771
821
  /**
772
822
  * End a managed chat SESSION outright, by session id (issue #731). Where
773
823
  * {@link cancel} ends the model turn — and on the session runtime deliberately
@@ -820,7 +870,7 @@ export declare class HerdctlService {
820
870
  * zero added cost unless the project actually declares hooks. The extra per-hook
821
871
  * scans only kick in for a project that has hook chats to show.
822
872
  */
823
- listSessions(project: Project): Promise<DiscoveredSession[]>;
873
+ listSessions(project: Project): Promise<SessionWithActivity[]>;
824
874
  /**
825
875
  * The on-disk job-record reads (run history + the unread badge) live in
826
876
  * `./herdctl-jobs.js` as pure functions over `<stateDir>/jobs` (issue #403);
@@ -62,6 +62,7 @@ import * as jobs from "./herdctl-jobs.js";
62
62
  import { JobsDirIndex } from "./herdctl-jobs-index.js";
63
63
  import { AdoptableIndex, } from "./adoptable.js";
64
64
  import { ROOT_KEY } from "./project-paths.js";
65
+ import { withLastActivity } from "./last-activity.js";
65
66
  /**
66
67
  * The name/visibility helpers + tool/model constants are pure (they only read
67
68
  * their arguments, no fleet/cfg/session state), so they live in a sibling module
@@ -1397,6 +1398,48 @@ export class HerdctlService {
1397
1398
  return false;
1398
1399
  }
1399
1400
  }
1401
+ /**
1402
+ * Stop ONE background task, by session id and SDK task id (issue #848).
1403
+ *
1404
+ * Deliberately keyed on the SESSION, not on {@link liveSessions}. That map is
1405
+ * emptied in a `finally` the moment the primary turn's result lands, while the
1406
+ * background tasks it launched keep running — so a `liveSessions`-keyed stop
1407
+ * would go inert during exactly the phase the running-work bar exists for.
1408
+ * `stopTaskInSession` is herdctl's answer to that gap: the reaper holds the
1409
+ * session across it.
1410
+ *
1411
+ * There is deliberately NO liveness pre-check. A reap landing between render
1412
+ * and click is a routine race, and `false` already describes it precisely.
1413
+ *
1414
+ * The three outcomes are kept DISTINCT all the way to the UI, because they
1415
+ * mean genuinely different things and only one of them is a failure:
1416
+ *
1417
+ * - `stopping` — the request was accepted. Nothing is evicted here: the SDK
1418
+ * emits the terminal `task_notification{status:"stopped"}` on the session's
1419
+ * own stream and {@link BackgroundRegistry} already evicts on it. This is
1420
+ * also the idempotent case — the CLI answers `not_found` / `not_running`
1421
+ * with a success, so a click racing a natural completion lands here.
1422
+ * - `gone` — no live session with that id. NOT a failure: the tasks died with
1423
+ * the session, so the user's intent is already satisfied. The caller drops
1424
+ * the row itself, because there is no stream left to notify on.
1425
+ * - `error` — the stop did NOT happen and the task is still running. Notably
1426
+ * a `monitor_mcp` task, which the CLI has no kill strategy for and rejects
1427
+ * with `unsupported_type`. Surfaced rather than reported as success.
1428
+ */
1429
+ async stopBackgroundTask(sessionId, taskId) {
1430
+ const fleet = this.fleet;
1431
+ if (!fleet)
1432
+ return { outcome: "gone" };
1433
+ try {
1434
+ const stopped = await fleet.stopTaskInSession(sessionId, taskId);
1435
+ return stopped ? { outcome: "stopping" } : { outcome: "gone" };
1436
+ }
1437
+ catch (err) {
1438
+ const message = err instanceof Error ? err.message : String(err);
1439
+ console.warn(`[herdctl] stopTaskInSession failed for ${sessionId}/${taskId}:`, err);
1440
+ return { outcome: "error", message };
1441
+ }
1442
+ }
1400
1443
  /**
1401
1444
  * End a managed chat SESSION outright, by session id (issue #731). Where
1402
1445
  * {@link cancel} ends the model turn — and on the session runtime deliberately
@@ -1477,9 +1520,9 @@ export class HerdctlService {
1477
1520
  */
1478
1521
  async listSessions(project) {
1479
1522
  const agentNames = visibleProjectAgentNames(project);
1480
- if (agentNames.length === 1)
1481
- return this.manager.getAgentSessions(agentNames[0]);
1482
- const perAgent = await Promise.all(agentNames.map((name) => this.manager.getAgentSessions(name).catch(() => [])));
1523
+ const perAgent = agentNames.length === 1
1524
+ ? [await this.manager.getAgentSessions(agentNames[0])]
1525
+ : await Promise.all(agentNames.map((name) => this.manager.getAgentSessions(name).catch(() => [])));
1483
1526
  const seen = new Set();
1484
1527
  const merged = [];
1485
1528
  for (const list of perAgent) {
@@ -1490,9 +1533,13 @@ export class HerdctlService {
1490
1533
  merged.push(s);
1491
1534
  }
1492
1535
  }
1493
- // ISO-8601 mtimes sort lexicographically in chronological order → descending.
1494
- merged.sort((a, b) => (a.mtime < b.mtime ? 1 : a.mtime > b.mtime ? -1 : 0));
1495
- return merged;
1536
+ // Recency, from the last real MESSAGE in each transcript rather than from
1537
+ // its file mtime (#863) — and this is also where the single-agent fast path
1538
+ // stopped being free. It used to return herdctl's own mtime-descending
1539
+ // order untouched; that order is the bug, so the common case has to be
1540
+ // re-sorted too. What it costs is a memoised tail read per chat, which is
1541
+ // why `lastMessageAt` seeks to the end rather than parsing.
1542
+ return withLastActivity(project.dir, merged);
1496
1543
  }
1497
1544
  /**
1498
1545
  * The on-disk job-record reads (run history + the unread badge) live in
@@ -0,0 +1,34 @@
1
+ import type { DiscoveredSession } from "@herdctl/core";
2
+ /**
3
+ * A session plus the timestamp of its last real message.
4
+ *
5
+ * A widening rather than a replacement, because `mtime` still has a job:
6
+ * it is the cache key for auto-name, preview, sidechain detection and usage
7
+ * throughout this package and inside herdctl. Overwriting it with the corrected
8
+ * value would fix the display and silently break every one of those caches —
9
+ * they would stop noticing that a file had changed.
10
+ */
11
+ export interface SessionWithActivity extends DiscoveredSession {
12
+ /** ISO 8601 timestamp of the last real message; absent if none was datable. */
13
+ lastMessageAt?: string;
14
+ }
15
+ /**
16
+ * Timestamp of the last real message in a project chat's transcript.
17
+ *
18
+ * `undefined` — a missing file, an unreadable one, a transcript of nothing but
19
+ * control records — is a legitimate answer meaning "nothing better than the
20
+ * mtime", and every caller treats it that way.
21
+ */
22
+ export declare function lastMessageAt(projectDir: string, sessionId: string): Promise<string | undefined>;
23
+ /**
24
+ * Attach `lastMessageAt` to each session and order them newest-first.
25
+ *
26
+ * The sort lives here rather than in the client because the client is not the
27
+ * only reader: the MCP `list_chats` tool and the project-detail payload both
28
+ * take the server's order as given. The comparison falls back to `mtime`
29
+ * per-session rather than for the list as a whole, so a chat with no datable
30
+ * record still sorts sensibly against ones that have.
31
+ */
32
+ export declare function withLastActivity(projectDir: string, sessions: readonly DiscoveredSession[]): Promise<SessionWithActivity[]>;
33
+ /** Epoch ms a session should be ordered by: its last message, else its mtime. */
34
+ export declare function activityMs(s: SessionWithActivity): number;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * When a chat was last *actually* used, as opposed to when its file was last
3
+ * touched (#863).
4
+ *
5
+ * ## The bug this exists to remove
6
+ *
7
+ * The chat list's "updated X ago" — and the order the list is in — came from
8
+ * the transcript `.jsonl`'s filesystem **mtime**. That is a property of the
9
+ * file, not of the conversation, and Paddock touches transcripts for reasons
10
+ * that have nothing to do with anyone talking: discovery re-stats, attribution,
11
+ * a resume appending a mode record, a relocation. Every one of those bumps the
12
+ * mtime with no new message in the file. The visible result was days-old chats
13
+ * announcing themselves as "updated a few minutes ago" — in *batches*,
14
+ * phase-locked to a periodic task, because that is what actually moved them —
15
+ * and jumping to the top of a list sorted by recency. Nothing was lost; the
16
+ * ordering was simply not about what it claimed to be about.
17
+ *
18
+ * So: read the timestamp of the last real message **inside** the transcript,
19
+ * and fall back to mtime only when there is no datable record to read.
20
+ *
21
+ * ## Why a tail read rather than a parse
22
+ *
23
+ * The value wanted is on the last qualifying line, so this seeks to the end and
24
+ * walks backwards, stopping at the first line that qualifies. A transcript is
25
+ * append-only, so that is a couple of pages of IO regardless of whether the
26
+ * chat holds ten messages or ten thousand. `@herdctl/core` does compute this
27
+ * during `extractSessionMetadata`, but as part of a whole-file parse the
28
+ * listing path deliberately does not do — reusing it would have traded a
29
+ * cosmetic bug for a real one.
30
+ *
31
+ * Results are memoised against mtime (the same LRU the subagent and usage
32
+ * readers use). Note the irony, and that it is fine: a spurious touch — the
33
+ * very thing this module exists to ignore — still invalidates the cache and
34
+ * costs one tail read. It buys correctness in the other direction, where a
35
+ * touch accompanies a genuine append, and a tail read is cheap enough that
36
+ * paying it on a false alarm is not worth a subtler key.
37
+ */
38
+ import fs from "node:fs/promises";
39
+ import path from "node:path";
40
+ import { projectChatsDir } from "./transcripts.js";
41
+ import { mtimeCacheGet, mtimeCacheSet, statMtimeMs } from "./subagents.js";
42
+ /** How much of the file's end to look at before widening the window. */
43
+ const TAIL_BYTES = 128 * 1024;
44
+ /** The second and last attempt — enough to clear a single enormous tool result. */
45
+ const WIDE_TAIL_BYTES = 4 * 1024 * 1024;
46
+ const cache = new Map();
47
+ /**
48
+ * Does this record represent a human or the agent actually saying something?
49
+ *
50
+ * The exclusions are the point of the whole issue, not defensive noise. A
51
+ * resume, a permission-mode change or a background agent finishing all append
52
+ * records to a transcript **without any turn having happened**, and counting
53
+ * those as activity reintroduces the same bug one layer up: a chat nobody has
54
+ * touched in a week, freshly stamped, back at the top of the list.
55
+ */
56
+ function isConversation(rec) {
57
+ // Drops `summary` title entries and every `type: "system"` control line
58
+ // (local-command records, permission-mode changes, hook output).
59
+ if (rec.type !== "user" && rec.type !== "assistant")
60
+ return false;
61
+ // Harness-injected synthetic user lines: skill bodies, slash-command
62
+ // expansions, hook output. Nobody typed them.
63
+ if (rec.isMeta === true)
64
+ return false;
65
+ // A spawned agent's stop/complete block. Real work, but not a turn in THIS
66
+ // chat, and it lands without the user or the agent here saying anything.
67
+ if (rec.origin?.kind === "task-notification")
68
+ return false;
69
+ return true;
70
+ }
71
+ /** The last `bytes` of `file`, plus whether that covered the whole thing. */
72
+ async function readTail(file, size, bytes) {
73
+ const take = Math.min(bytes, size);
74
+ const start = size - take;
75
+ const handle = await fs.open(file, "r");
76
+ try {
77
+ const buf = Buffer.alloc(take);
78
+ await handle.read(buf, 0, take, start);
79
+ // A byte offset can land mid-character; only the first line can be affected
80
+ // and the caller discards it whenever `whole` is false.
81
+ return { text: buf.toString("utf8"), whole: start === 0 };
82
+ }
83
+ finally {
84
+ await handle.close();
85
+ }
86
+ }
87
+ /** Walk a window backwards for the newest qualifying record's timestamp. */
88
+ function scanBackwards(text, whole) {
89
+ const lines = text.split("\n");
90
+ // Seeking to a byte offset almost certainly landed inside a line. Dropping it
91
+ // costs nothing (it is the OLDEST line in the window) and keeps a fragment
92
+ // from being mistaken for a malformed record.
93
+ if (!whole)
94
+ lines.shift();
95
+ for (let i = lines.length - 1; i >= 0; i--) {
96
+ const line = lines[i].trim();
97
+ if (!line)
98
+ continue;
99
+ let rec;
100
+ try {
101
+ rec = JSON.parse(line);
102
+ }
103
+ catch {
104
+ continue;
105
+ }
106
+ if (!isConversation(rec))
107
+ continue;
108
+ const ts = rec.timestamp;
109
+ if (typeof ts === "string" && !Number.isNaN(Date.parse(ts)))
110
+ return ts;
111
+ }
112
+ return undefined;
113
+ }
114
+ /**
115
+ * Timestamp of the last real message in a project chat's transcript.
116
+ *
117
+ * `undefined` — a missing file, an unreadable one, a transcript of nothing but
118
+ * control records — is a legitimate answer meaning "nothing better than the
119
+ * mtime", and every caller treats it that way.
120
+ */
121
+ export async function lastMessageAt(projectDir, sessionId) {
122
+ // Same guard as `readFirstUserText`: keep a session id from walking out of
123
+ // `.chats/`.
124
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId))
125
+ return undefined;
126
+ const file = path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`);
127
+ const mtimeMs = await statMtimeMs(file);
128
+ if (mtimeMs === undefined)
129
+ return undefined;
130
+ const cached = mtimeCacheGet(cache, file, mtimeMs);
131
+ if (cached.hit)
132
+ return cached.value;
133
+ let found;
134
+ try {
135
+ const { size } = await fs.stat(file);
136
+ if (size > 0) {
137
+ const near = await readTail(file, size, TAIL_BYTES);
138
+ found = scanBackwards(near.text, near.whole);
139
+ // Nothing qualified, and there is more file to look at: one transcript
140
+ // record can be megabytes (a large tool result), so the window can hold a
141
+ // single unterminated line. One wider retry, then give up to the mtime.
142
+ if (found === undefined && !near.whole) {
143
+ const far = await readTail(file, size, WIDE_TAIL_BYTES);
144
+ found = scanBackwards(far.text, far.whole);
145
+ }
146
+ }
147
+ }
148
+ catch {
149
+ found = undefined;
150
+ }
151
+ mtimeCacheSet(cache, file, mtimeMs, found);
152
+ return found;
153
+ }
154
+ /**
155
+ * Attach `lastMessageAt` to each session and order them newest-first.
156
+ *
157
+ * The sort lives here rather than in the client because the client is not the
158
+ * only reader: the MCP `list_chats` tool and the project-detail payload both
159
+ * take the server's order as given. The comparison falls back to `mtime`
160
+ * per-session rather than for the list as a whole, so a chat with no datable
161
+ * record still sorts sensibly against ones that have.
162
+ */
163
+ export async function withLastActivity(projectDir, sessions) {
164
+ const enriched = await Promise.all(sessions.map(async (s) => {
165
+ const at = await lastMessageAt(projectDir, s.sessionId).catch(() => undefined);
166
+ return at === undefined ? { ...s } : { ...s, lastMessageAt: at };
167
+ }));
168
+ // ISO-8601 sorts lexicographically in chronological order, but only against
169
+ // another ISO-8601 string in the same shape — `Date.parse` rather than `<`,
170
+ // because `lastMessageAt` comes out of a transcript written by Claude Code
171
+ // and `mtime` out of `toISOString()`, and mixing offsets would otherwise
172
+ // compare as text.
173
+ return enriched.sort((a, b) => activityMs(b) - activityMs(a));
174
+ }
175
+ /** Epoch ms a session should be ordered by: its last message, else its mtime. */
176
+ export function activityMs(s) {
177
+ return Date.parse(s.lastMessageAt ?? s.mtime) || 0;
178
+ }
@@ -113,7 +113,11 @@ export function buildManagementOps(ctx, params) {
113
113
  project: p.slug,
114
114
  sessionId: s.sessionId,
115
115
  name: s.customName ?? s.autoName ?? s.sessionId.slice(0, 8),
116
- updatedAt: s.mtime,
116
+ // Last real message, else the file mtime (#863) — the same rule
117
+ // `chat-dto.ts` applies, because the MCP list and the web list
118
+ // disagreeing about when a chat was last used is how a background
119
+ // agent picks the wrong chat to follow up on.
120
+ updatedAt: s.lastMessageAt ?? s.mtime,
117
121
  running: hub.isRunning(s.sessionId),
118
122
  // #489: the archived flag the web UI has always had (`chat-dto.ts`) but
119
123
  // the MCP surface never reported — so the two disagreed about what "the
@@ -1436,8 +1436,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1436
1436
  app.get("/adoptable-chats", {
1437
1437
  schema: {
1438
1438
  tags: ["Chats"],
1439
- summary: "Count the native Claude Code chats this project could import",
1440
- 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.",
1439
+ summary: "Count the native Claude Code chats this project could adopt",
1440
+ description: "Returns the terminal `claude` sessions that could be adopted 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 when the project has a `repo:` key, by exact path otherwise). Sidechains, already-adopted 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 adoption rather than remembering a dismissal.",
1441
1441
  params: {
1442
1442
  type: "object",
1443
1443
  properties: {
@@ -1474,8 +1474,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1474
1474
  app.post("/adopt-chats", {
1475
1475
  schema: {
1476
1476
  tags: ["Chats"],
1477
- summary: "Import native Claude Code chats into this project",
1478
- 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`).",
1477
+ summary: "Adopt native Claude Code chats into this project",
1478
+ description: "Adopts the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is adopted; 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 adoption 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`).",
1479
1479
  params: {
1480
1480
  type: "object",
1481
1481
  properties: {
@@ -1500,7 +1500,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1500
1500
  },
1501
1501
  response: {
1502
1502
  200: {
1503
- description: "Object `{ adopted, skipped }` describing what was imported.",
1503
+ description: "Object `{ adopted, skipped }` describing what was adopted.",
1504
1504
  type: "object",
1505
1505
  additionalProperties: true,
1506
1506
  },
@@ -1549,8 +1549,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1549
1549
  app.post("/unadopt-chats", {
1550
1550
  schema: {
1551
1551
  tags: ["Chats"],
1552
- summary: "Undo the most recent native-chat import into this project",
1553
- 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).",
1552
+ summary: "Undo the most recent native-chat adoption into this project",
1553
+ description: "Reverses the adoption this server most recently performed into the workspace: each adoption is released and the copies that adoption placed are deleted, leaving the user's own `~/.claude` history untouched. `sessionIds` undoes part of that adoption; omit it to undo all of it. Deliberately scoped to the LAST adoption 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).",
1554
1554
  params: {
1555
1555
  type: "object",
1556
1556
  properties: {
@@ -37,7 +37,7 @@
37
37
  * here; on a shared authenticated instance it is worth knowing about.
38
38
  */
39
39
  import path from "node:path";
40
- import { defaultTempRoots, discoverCandidates, discoverSessions, DiscoverPathError, } from "../discover.js";
40
+ import { defaultTempRoots, discoverCandidates, discoverSessions, DiscoverPathError, withoutOwnTranscriptFolders, } from "../discover.js";
41
41
  import { ROOT_KEY } from "../project-paths.js";
42
42
  /** A `?flag=` that is present and not explicitly falsy. */
43
43
  function flag(value) {
@@ -54,7 +54,14 @@ export function registerDiscoverRoutes(app, ctx) {
54
54
  async function context() {
55
55
  const existing = [...(await projects.list()), await projects.get(ROOT_KEY)];
56
56
  return {
57
- folders: await herdctl.transcriptFolders(),
57
+ // Paddock's own `.chats` bridges are removed BEFORE the scan sees them, so
58
+ // they are not counted in `scanned` either (#865). A pristine instance has
59
+ // two of them and nothing else, and counting those was what made
60
+ // `scanned === 0` — "no history on this machine" — unreachable.
61
+ folders: withoutOwnTranscriptFolders(await herdctl.transcriptFolders(), {
62
+ ownRoots: [cfg.projectsRoot, cfg.dataDir],
63
+ claudeHomes: [cfg.claudeHome, cfg.legacyClaudeHome],
64
+ }),
58
65
  claudeHome: cfg.claudeHome,
59
66
  // The user's own home, taken from the one place config already resolves it
60
67
  // (`legacyClaudeHome` is `os.homedir()/.claude`, with no override) so this
@@ -73,7 +80,7 @@ export function registerDiscoverRoutes(app, ctx) {
73
80
  schema: {
74
81
  tags: ["System"],
75
82
  summary: "Directories on this machine that could become projects",
76
- description: "Scans `<claudeHome>/projects/*` for directories with existing Claude Code history and returns the ones worth importing, ranked by how many non-noise sessions each holds. Never walks the filesystem: the only directories it can name are ones a transcript already records, and each is put through the same path floor a linked project must clear (no system directories, no temp roots, nothing inside Paddock's own data dir, nothing overlapping an existing project). Two rules are relaxable: `includeNonGit=1` offers directories that are not git checkouts, `includeOutsideHome=1` offers directories outside `$HOME`. `excluded` reports how many folders each rule ate — a container whose Claude home is empty reports `scanned: 0`, which is what distinguishes 'no history' from 'all of it was filtered'. Response: `{ claudeHome, homeDir, scanned, candidates, excluded }`.",
83
+ description: "Scans `<claudeHome>/projects/*` for directories with existing Claude Code history and returns the ones worth importing, ranked by how many non-noise sessions each holds. Never walks the filesystem: the only directories it can name are ones a transcript already records, and each is put through the same path floor a linked project must clear (no system directories, no temp roots, nothing inside Paddock's own data dir, nothing overlapping an existing project). Two rules are relaxable: `includeNonGit=1` offers directories that are not git checkouts, `includeOutsideHome=1` offers directories outside `$HOME`. Note the two counts are in DIFFERENT units and do not reconcile by subtraction: `scanned` counts transcript FOLDERS, while `excluded` reports how many DIRECTORIES each rule ate — one directory routinely has two transcript folders (the user's own and Paddock's copy), so a rule that ate one directory may account for two scanned folders. The single exception is `no-recorded-cwd`, which is necessarily per-folder: a folder with no recorded cwd has no directory to be counted under. A machine with no Claude Code history reports `scanned: 0`, which is what distinguishes 'no history' from 'all of it was filtered'. Paddock's own `.chats` bridge folders are excluded before that count is taken — they exist on an instance nobody has used yet, and counting them made `scanned: 0` unreachable. Response: `{ claudeHome, homeDir, scanned, candidates, excluded }`.",
77
84
  querystring: {
78
85
  type: "object",
79
86
  properties: {
@@ -68,7 +68,10 @@ export interface SelfMcpChat {
68
68
  sessionId: string;
69
69
  /** Display name (custom name, else auto-name, else a short id). */
70
70
  name: string;
71
- /** ISO timestamp of the last transcript write (mtime). */
71
+ /**
72
+ * ISO timestamp of the last real message in the transcript (#863) — the file's
73
+ * mtime only when no message in it carries a usable date.
74
+ */
72
75
  updatedAt: string;
73
76
  /** Whether a turn is currently in flight for this chat. */
74
77
  running: boolean;
@@ -179,9 +179,11 @@ export async function ensureProjectChats(workingDir, chatsHostDir, home) {
179
179
  // cp+rm is robust across filesystems (rename would EXDEV across mounts).
180
180
  // `preserveTimestamps` is load-bearing, not cosmetic (#588): fs.cp
181
181
  // defaults to stamping the copy with NOW, and a transcript's mtime is
182
- // both the chat-list sort key and the cache key for auto-name / preview /
183
- // sidechain detection — so without it, relocating a months-old archive
184
- // collapses every one of those chats to "today".
182
+ // the cache key for auto-name / preview / sidechain detection — so
183
+ // without it, relocating a months-old archive re-derives every one of
184
+ // those. It is no longer the chat-list sort key (#863, that now comes
185
+ // from the last message INSIDE the transcript), which narrows the blast
186
+ // radius of getting this wrong but does not remove it.
185
187
  await fs.cp(from, to, { recursive: true, preserveTimestamps: true });
186
188
  await fs.rm(from, { recursive: true, force: true });
187
189
  }