@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.
- package/package.json +2 -2
- package/packages/server/dist/background-live.d.ts +24 -0
- package/packages/server/dist/background-live.js +38 -0
- package/packages/server/dist/chat-dto.d.ts +3 -2
- package/packages/server/dist/chat-dto.js +9 -1
- package/packages/server/dist/cli/args.d.ts +11 -3
- package/packages/server/dist/cli/args.js +33 -7
- package/packages/server/dist/cli/paddock.js +3 -3
- package/packages/server/dist/cli/service/index.d.ts +10 -1
- package/packages/server/dist/cli/service/index.js +55 -6
- package/packages/server/dist/cli/service/ready.d.ts +61 -0
- package/packages/server/dist/cli/service/ready.js +71 -0
- package/packages/server/dist/discover.d.ts +37 -0
- package/packages/server/dist/discover.js +51 -0
- package/packages/server/dist/herdctl.d.ts +51 -1
- package/packages/server/dist/herdctl.js +53 -6
- package/packages/server/dist/last-activity.d.ts +34 -0
- package/packages/server/dist/last-activity.js +178 -0
- package/packages/server/dist/management-ops.js +5 -1
- package/packages/server/dist/routes/chats.js +7 -7
- package/packages/server/dist/routes/discover.js +10 -3
- package/packages/server/dist/self-mcp-types.d.ts +4 -1
- package/packages/server/dist/transcripts.js +5 -3
- package/packages/server/dist/ws-protocol.d.ts +78 -2
- package/packages/server/dist/ws-protocol.js +9 -0
- package/packages/server/dist/ws.js +38 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/Chip-Db--PJFH.js +2 -0
- package/packages/web/dist/assets/DiscoverPage-BFo4FTzZ.js +2 -0
- package/packages/web/dist/assets/DiscoverView-C4hOB3nr.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-U78M95nW.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DatKu1Pl.js → MarkdownRenderer-DdoNl0Ao.js} +2 -2
- package/packages/web/dist/assets/{ProjectMenu-DJB7uCGt.js → ProjectMenu-BxQu14ze.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-D5Uxki7M.js → ProjectRedirect-QuGeN-qx.js} +1 -1
- package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-CqQTeqdE.js → ProjectsGrid-CUFNVTif.js} +1 -1
- package/packages/web/dist/assets/RootHome-SIxo-xX9.js +2 -0
- package/packages/web/dist/assets/{arc-Bvnrokeb.js → arc-C0DRwCCT.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc5OXCmK.js → architectureDiagram-3BPJPVTR-Wwpf4cvW.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-CFZeUqSH.js → blockDiagram-GPEHLZMM-DKmCbgWk.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BPI5UmpX.js → c4Diagram-AAUBKEIU-CJ8LiLsj.js} +1 -1
- package/packages/web/dist/assets/channel-DEmtNKeB.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-CVpthvPT.js → chunk-2J33WTMH-C3P-O4C3.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-cTaBnmzz.js → chunk-4BX2VUAB-Cs9u7x61.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-dkgF9sAS.js → chunk-55IACEB6-C1st284U.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-UiFZch2W.js → chunk-727SXJPM-BjCAWo7B.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-IKsoQNZy.js → chunk-AQP2D5EJ-BP6S9w2Q.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-Tzt_HckZ.js → chunk-FMBD7UC4-C3ySTaPs.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-dbNwUFdT.js → chunk-ND2GUHAM-eNPD4vzc.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-eUsG1OkP.js → chunk-QZHKN3VN-BbBpgHkO.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-dAGAuccL.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-dAGAuccL.js +2 -0
- package/packages/web/dist/assets/{core-Duun4AtS.js → core-ZwDDec0y.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-KF_gNdsd.js → cose-bilkent-S5V4N54A-DiM4eeF5.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-CXFDGeP7.js → dagre-BM42HDAG-DKIEUM4M.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BiefFjo3.js → diagram-2AECGRRQ-CqlX7j8t.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-B1IpANC8.js → diagram-5GNKFQAL-Dph5apqo.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-ChdOA3EK.js → diagram-KO2AKTUF-TW6LyHGt.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BTbLDsXf.js → diagram-LMA3HP47-BxxIIv5a.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-Bqiqt27L.js → diagram-OG6HWLK6-B8M0DeV7.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-KA9WLkV0.js → erDiagram-TEJ5UH35-Bfpy6UQa.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CZKvoeTz.js → flowDiagram-I6XJVG4X-DstRzxek.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-o1QDzlgt.js → ganttDiagram-6RSMTGT7-Bg0oFbAl.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-D5GdOppF.js → gitGraphDiagram-PVQCEYII-CX4IozE-.js} +1 -1
- package/packages/web/dist/assets/{index-B-J2K1w2.js → index-ASW1Jl0Y.js} +11 -11
- package/packages/web/dist/assets/index-bC5uCujx.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-21Z9hMO-.js → infoDiagram-5YYISTIA-CG2qCk6u.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-TmbPK40i.js → ishikawaDiagram-YF4QCWOH-CPErvl3a.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DYAWKu-K.js → journeyDiagram-JHISSGLW-Bm6krUsf.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-ZW_ZQ0DQ.js → kanban-definition-UN3LZRKU-B4CC8CAx.js} +1 -1
- package/packages/web/dist/assets/{linear-BDQKnwHn.js → linear-CJ1DbrOX.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-BHXnmT7N.js → mermaid.core-Dk2kkhf9.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CHVPYLQs.js → mindmap-definition-RKZ34NQL-CThvf_YD.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-paqmu057.js → pieDiagram-4H26LBE5-DvkHcng6.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-diM_4CGS.js → quadrantDiagram-W4KKPZXB-CqhrTh9q.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DCyNbyIz.js → requirementDiagram-4Y6WPE33-CZykiLym.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DxpEvGOy.js → sankeyDiagram-5OEKKPKP-D-TzCKMx.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-BH4-7BdR.js → sequenceDiagram-3UESZ5HK-C5pIItVu.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CVPrwckU.js → stateDiagram-AJRCARHV-DE6G2JfB.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BP6oowh1.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-B6_yfyHl.js → timeline-definition-PNZ67QCA-C1VK2OfU.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Ic8OogjJ.js → vennDiagram-CIIHVFJN-CtOozEwa.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CuhgMTiv.js → wardley-L42UT6IY-DvHGK-lb.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Djeda9cE.js → wardleyDiagram-YWT4CUSO-DxKwZ3-G.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D3hl8Mdh.js → xychartDiagram-2RQKCTM6-HM4j3l6y.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/Chip-P4n88XYj.js +0 -2
- package/packages/web/dist/assets/DiscoverPage-B7ap1I1Y.js +0 -2
- package/packages/web/dist/assets/DiscoverView-DxC3mmGp.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-DO87_86Z.js +0 -2
- package/packages/web/dist/assets/ProjectView-D8TqrBW4.js +0 -13
- package/packages/web/dist/assets/RootHome-B0si7ijq.js +0 -2
- package/packages/web/dist/assets/Surfaces-t3XF_my5.js +0 -2
- package/packages/web/dist/assets/channel-BGIf4GoQ.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-6kluxBAR.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-6kluxBAR.js +0 -2
- package/packages/web/dist/assets/index-4ugpmAz4.css +0 -1
- 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<
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
//
|
|
1494
|
-
|
|
1495
|
-
return
|
|
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
|
-
|
|
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
|
|
1440
|
-
description: "Returns the terminal `claude` sessions that could be
|
|
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: "
|
|
1478
|
-
description: "
|
|
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
|
|
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
|
|
1553
|
-
description: "Reverses the
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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
|
}
|