@edspencer/paddock 0.70.0 → 0.71.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 (94) hide show
  1. package/package.json +2 -2
  2. package/packages/server/dist/background-live.d.ts +40 -8
  3. package/packages/server/dist/background-live.js +79 -1
  4. package/packages/server/dist/cli/args.d.ts +11 -3
  5. package/packages/server/dist/cli/args.js +33 -7
  6. package/packages/server/dist/cli/service/index.js +12 -1
  7. package/packages/server/dist/git.d.ts +63 -8
  8. package/packages/server/dist/git.js +100 -20
  9. package/packages/server/dist/herdctl-jobs.d.ts +30 -4
  10. package/packages/server/dist/herdctl-jobs.js +35 -8
  11. package/packages/server/dist/herdctl.d.ts +49 -0
  12. package/packages/server/dist/herdctl.js +42 -0
  13. package/packages/server/dist/project-files.d.ts +18 -7
  14. package/packages/server/dist/project-files.js +38 -34
  15. package/packages/server/dist/projects.d.ts +1 -1
  16. package/packages/server/dist/projects.js +14 -7
  17. package/packages/server/dist/routes/chats.js +7 -7
  18. package/packages/server/dist/routes/discover.js +1 -1
  19. package/packages/server/dist/routes/git.d.ts +2 -2
  20. package/packages/server/dist/routes/git.js +133 -1
  21. package/packages/server/dist/ws-protocol.d.ts +89 -3
  22. package/packages/server/dist/ws-protocol.js +9 -0
  23. package/packages/server/dist/ws.js +38 -0
  24. package/packages/server/package.json +1 -1
  25. package/packages/web/dist/assets/{Chip-C8a-t439.js → Chip-5S5MAayn.js} +1 -1
  26. package/packages/web/dist/assets/DiscoverPage-CEzcRO7X.js +2 -0
  27. package/packages/web/dist/assets/DiscoverView-CArICIRO.js +2 -0
  28. package/packages/web/dist/assets/InstanceConfigPage-CKjJsaGZ.js +2 -0
  29. package/packages/web/dist/assets/{MarkdownRenderer-DCj_ZwVG.js → MarkdownRenderer-BOrDawoY.js} +1 -1
  30. package/packages/web/dist/assets/{ProjectMenu-B7dGZSFB.js → ProjectMenu-B_8NYWZr.js} +1 -1
  31. package/packages/web/dist/assets/{ProjectRedirect-bstvMvM9.js → ProjectRedirect-xFWTlADS.js} +1 -1
  32. package/packages/web/dist/assets/ProjectView-polknqZw.js +13 -0
  33. package/packages/web/dist/assets/{ProjectsGrid-DKvySDUQ.js → ProjectsGrid-BF65wTqN.js} +1 -1
  34. package/packages/web/dist/assets/{RootHome-CD_37qb-.js → RootHome-32k1iDh4.js} +1 -1
  35. package/packages/web/dist/assets/{Surfaces-1dBayrsj.js → Surfaces-DCm19ZAD.js} +1 -1
  36. package/packages/web/dist/assets/{arc-BJPb3ViJ.js → arc-7GXE70CZ.js} +1 -1
  37. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-PkLOC5wV.js → architectureDiagram-3BPJPVTR-CAEj_lAw.js} +1 -1
  38. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-IPduhNH-.js → blockDiagram-GPEHLZMM-wNLONvdQ.js} +1 -1
  39. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-dKTYV1Vq.js → c4Diagram-AAUBKEIU-Cf7MUqJz.js} +1 -1
  40. package/packages/web/dist/assets/channel-U0NZSTs2.js +2 -0
  41. package/packages/web/dist/assets/{chunk-2J33WTMH-B5C6geyg.js → chunk-2J33WTMH-W18BVHtE.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-4BX2VUAB-1BRhFRhF.js → chunk-4BX2VUAB-DDJQi9KC.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-55IACEB6-DNv-sTiI.js → chunk-55IACEB6-L5afa49k.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-727SXJPM-CWezuVkx.js → chunk-727SXJPM-Bb0Sw8oK.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-AQP2D5EJ-Bt4yEQnu.js → chunk-AQP2D5EJ-DRrKTv1K.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-FMBD7UC4-lb_8q4JY.js → chunk-FMBD7UC4-CIsdDBaT.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-ND2GUHAM-DOwgGlQ_.js → chunk-ND2GUHAM-DN5ua8KT.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-QZHKN3VN-BanKpY4q.js → chunk-QZHKN3VN-DE1WNShj.js} +1 -1
  49. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D1QjNKTc.js +2 -0
  50. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D1QjNKTc.js +2 -0
  51. package/packages/web/dist/assets/{core-CAsyoNAa.js → core-DadwNpE4.js} +1 -1
  52. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-sVaVkwMI.js → cose-bilkent-S5V4N54A-2FTPTDA6.js} +1 -1
  53. package/packages/web/dist/assets/{dagre-BM42HDAG-C9pKHSMo.js → dagre-BM42HDAG-eJ7MdaEX.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-2AECGRRQ-CHdrmCfX.js → diagram-2AECGRRQ-D8MZTSkB.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-5GNKFQAL-4UTa2Dfa.js → diagram-5GNKFQAL-DVXF3Z-H.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-KO2AKTUF-NidpfVwW.js → diagram-KO2AKTUF-DoXfbQ0i.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-LMA3HP47-ictQj03T.js → diagram-LMA3HP47-CRW0fiqv.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-OG6HWLK6-B7dTLMHC.js → diagram-OG6HWLK6-DfhYXDqr.js} +1 -1
  59. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C2QYYzJ8.js → erDiagram-TEJ5UH35-HG0QTHgA.js} +1 -1
  60. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D2rcjSZ9.js → flowDiagram-I6XJVG4X-ChVekwUy.js} +1 -1
  61. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BQcXTYzg.js → ganttDiagram-6RSMTGT7-CeA-6Z-T.js} +1 -1
  62. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DwB2Vqxa.js → gitGraphDiagram-PVQCEYII-ew2oouNN.js} +1 -1
  63. package/packages/web/dist/assets/{index-BeuEs7dL.js → index-CbqaEg2j.js} +14 -14
  64. package/packages/web/dist/assets/index-tvJIh4A9.css +1 -0
  65. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BoK4Mawg.js → infoDiagram-5YYISTIA-BY9NYdld.js} +1 -1
  66. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DPP9svhg.js → ishikawaDiagram-YF4QCWOH-CgbdrGBm.js} +1 -1
  67. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-TrggodYk.js → journeyDiagram-JHISSGLW-BepLFtbU.js} +1 -1
  68. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Bh69hRSR.js → kanban-definition-UN3LZRKU-JHMJYGJu.js} +1 -1
  69. package/packages/web/dist/assets/{linear-C1iYMpBz.js → linear-Ccw8eVgC.js} +1 -1
  70. package/packages/web/dist/assets/{mermaid.core-BZDABcuq.js → mermaid.core-lHjASk7x.js} +4 -4
  71. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DTJON3dS.js → mindmap-definition-RKZ34NQL-Bi5Iq2M4.js} +1 -1
  72. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BxtFV74I.js → pieDiagram-4H26LBE5-rZuB_P1J.js} +1 -1
  73. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-D2Ar8kmO.js → quadrantDiagram-W4KKPZXB-BuStI37i.js} +1 -1
  74. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DiLSfeSQ.js → requirementDiagram-4Y6WPE33-BFElQKEX.js} +1 -1
  75. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D_CRQc7j.js → sankeyDiagram-5OEKKPKP-tzdQCr91.js} +1 -1
  76. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DjtfcRNV.js → sequenceDiagram-3UESZ5HK-j1koqb6Q.js} +1 -1
  77. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CzEtmYLV.js → stateDiagram-AJRCARHV-B-dUkvgq.js} +1 -1
  78. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-b1ksIBrs.js +2 -0
  79. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DXKspEG3.js → timeline-definition-PNZ67QCA-BWyq6u64.js} +1 -1
  80. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B_tXRMHH.js → vennDiagram-CIIHVFJN-B0CRl3xN.js} +1 -1
  81. package/packages/web/dist/assets/{wardley-L42UT6IY-DNaNoaJm.js → wardley-L42UT6IY-C45bBEPS.js} +1 -1
  82. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BuVDpC8s.js → wardleyDiagram-YWT4CUSO-DT50TWrW.js} +1 -1
  83. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Cqtew2PA.js → xychartDiagram-2RQKCTM6-C8iBuFGD.js} +1 -1
  84. package/packages/web/dist/index.html +2 -2
  85. package/packages/web/dist/sw.js +1 -1
  86. package/packages/web/dist/assets/DiscoverPage-C1khyCvD.js +0 -2
  87. package/packages/web/dist/assets/DiscoverView-DS_Z9xKH.js +0 -2
  88. package/packages/web/dist/assets/InstanceConfigPage-B1ei1FDo.js +0 -2
  89. package/packages/web/dist/assets/ProjectView-iuiUIoIl.js +0 -13
  90. package/packages/web/dist/assets/channel-BhrgMiqg.js +0 -2
  91. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Ho1IMdjg.js +0 -2
  92. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Ho1IMdjg.js +0 -2
  93. package/packages/web/dist/assets/index-BNzTD4hA.css +0 -1
  94. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-pM8fIOir.js +0 -2
@@ -124,19 +124,46 @@ export async function listProjectRuns(stateDir, project, limit = 100) {
124
124
  * the data source for the Triggers tab's per-trigger last-run column. Used to pull
125
125
  * one project's keeper AND every scoped `trigger-<slug>-<name>` agent in a single
126
126
  * pass so {@link import("./trigger-runtime.js").buildTriggerRuntime} can attribute a
127
- * scoped trigger's newest run by agent name. `listJobs` has no multi-agent filter,
128
- * so this scans the jobs dir once (unfiltered) and keeps only the requested agents;
129
- * order (started_at descending) is preserved. Errors swallow to `[]` so the runtime
130
- * view degrades to config-only rather than failing to render.
127
+ * scoped trigger's newest run by agent name. Order (started_at descending) is
128
+ * preserved. Errors swallow to `[]` so the runtime view degrades to config-only
129
+ * rather than failing to render.
130
+ *
131
+ * ## Why the filter is pushed down (#535)
132
+ *
133
+ * This used to call `listJobs(jobsDir)` with NEITHER filter nor limit, then
134
+ * filter and slice in JS — because `ListJobsFilter` had `agent` (exactly one)
135
+ * and no multi-agent form. That defeats core's job index completely: with
136
+ * `filter.limit === undefined` core sets `retain = matches`, so EVERY record in
137
+ * the shared, never-pruned, fleet-wide jobs dir is read, YAML-parsed and
138
+ * Zod-validated, then thrown away. Measured on a 2,016-record dir:
139
+ *
140
+ * | call | warm |
141
+ * |---|---|
142
+ * | `listJobs(dir)` + JS filter + slice | 1,935–2,095 ms |
143
+ * | `listJobs(dir, { agents, limit })` | 125–146 ms |
144
+ *
145
+ * That ~2 s was essentially the whole cost of `GET /triggers/runtime`, which the
146
+ * Triggers tab polls every 10 s.
147
+ *
148
+ * Both `agents` AND `limit` are load-bearing. `agents` alone still leaves
149
+ * `limit` undefined, so `retain = matches` and every match is hydrated anyway —
150
+ * the win comes from the pair. `limit <= 0` keeps its documented "no cap"
151
+ * meaning and is passed as no `limit`, which costs more but is still filtered.
152
+ *
153
+ * Requires `@herdctl/core` >= 5.28.0 (herdctl#418). On an older core `agents` is
154
+ * an unknown key and would be silently ignored, returning the newest `limit`
155
+ * records across ALL agents — hence the version floor in package.json rather
156
+ * than a defensive re-filter here, which would mask the mismatch.
131
157
  */
132
158
  export async function listRunsForAgents(stateDir, agents, limit = 200) {
133
159
  if (agents.length === 0)
134
160
  return [];
135
161
  const jobsDir = jobsDirOf(stateDir);
136
- const wanted = new Set(agents);
137
- const { jobs } = await listJobs(jobsDir).catch(() => ({ jobs: [], errors: 0 }));
138
- const filtered = jobs.filter((j) => wanted.has(j.agent));
139
- return limit > 0 ? filtered.slice(0, limit) : filtered;
162
+ // Core dedupes `agents` into a Set internally, so the caller's keeper +
163
+ // per-trigger list may repeat the keeper (unscoped schedule triggers run
164
+ // under it) exactly as it did when this deduped locally.
165
+ const { jobs } = await listJobs(jobsDir, limit > 0 ? { agents, limit } : { agents }).catch(() => ({ jobs: [], errors: 0 }));
166
+ return jobs;
140
167
  }
141
168
  /**
142
169
  * Delete every job record matching `match`, plus each one's sibling `.jsonl`
@@ -79,6 +79,26 @@ export interface ChatDeletion {
79
79
  /** The transcript was deliberately left on disk because it is not paddock's. */
80
80
  retained: boolean;
81
81
  }
82
+ /**
83
+ * What happened to a request to stop one background task (#848).
84
+ *
85
+ * Three cases, kept apart on purpose — see
86
+ * {@link HerdctlService.stopBackgroundTask} for why only `error` is a failure.
87
+ */
88
+ export type StopTaskOutcome =
89
+ /** Accepted. The SDK's own terminal notification is what removes the row. */
90
+ {
91
+ outcome: "stopping";
92
+ }
93
+ /** No live session — the work died with it. The caller drops the row. */
94
+ | {
95
+ outcome: "gone";
96
+ }
97
+ /** The stop did not happen and the task is still running. */
98
+ | {
99
+ outcome: "error";
100
+ message: string;
101
+ };
82
102
  /** Options passed through to a streamed trigger. */
83
103
  export interface ChatTurnOptions {
84
104
  prompt: string;
@@ -768,6 +788,35 @@ export declare class HerdctlService {
768
788
  * failure, so a broken Stop looked like a no-op).
769
789
  */
770
790
  cancel(jobId: string): Promise<boolean>;
791
+ /**
792
+ * Stop ONE background task, by session id and SDK task id (issue #848).
793
+ *
794
+ * Deliberately keyed on the SESSION, not on {@link liveSessions}. That map is
795
+ * emptied in a `finally` the moment the primary turn's result lands, while the
796
+ * background tasks it launched keep running — so a `liveSessions`-keyed stop
797
+ * would go inert during exactly the phase the running-work bar exists for.
798
+ * `stopTaskInSession` is herdctl's answer to that gap: the reaper holds the
799
+ * session across it.
800
+ *
801
+ * There is deliberately NO liveness pre-check. A reap landing between render
802
+ * and click is a routine race, and `false` already describes it precisely.
803
+ *
804
+ * The three outcomes are kept DISTINCT all the way to the UI, because they
805
+ * mean genuinely different things and only one of them is a failure:
806
+ *
807
+ * - `stopping` — the request was accepted. Nothing is evicted here: the SDK
808
+ * emits the terminal `task_notification{status:"stopped"}` on the session's
809
+ * own stream and {@link BackgroundRegistry} already evicts on it. This is
810
+ * also the idempotent case — the CLI answers `not_found` / `not_running`
811
+ * with a success, so a click racing a natural completion lands here.
812
+ * - `gone` — no live session with that id. NOT a failure: the tasks died with
813
+ * the session, so the user's intent is already satisfied. The caller drops
814
+ * the row itself, because there is no stream left to notify on.
815
+ * - `error` — the stop did NOT happen and the task is still running. Notably
816
+ * a `monitor_mcp` task, which the CLI has no kill strategy for and rejects
817
+ * with `unsupported_type`. Surfaced rather than reported as success.
818
+ */
819
+ stopBackgroundTask(sessionId: string, taskId: string): Promise<StopTaskOutcome>;
771
820
  /**
772
821
  * End a managed chat SESSION outright, by session id (issue #731). Where
773
822
  * {@link cancel} ends the model turn — and on the session runtime deliberately
@@ -1397,6 +1397,48 @@ export class HerdctlService {
1397
1397
  return false;
1398
1398
  }
1399
1399
  }
1400
+ /**
1401
+ * Stop ONE background task, by session id and SDK task id (issue #848).
1402
+ *
1403
+ * Deliberately keyed on the SESSION, not on {@link liveSessions}. That map is
1404
+ * emptied in a `finally` the moment the primary turn's result lands, while the
1405
+ * background tasks it launched keep running — so a `liveSessions`-keyed stop
1406
+ * would go inert during exactly the phase the running-work bar exists for.
1407
+ * `stopTaskInSession` is herdctl's answer to that gap: the reaper holds the
1408
+ * session across it.
1409
+ *
1410
+ * There is deliberately NO liveness pre-check. A reap landing between render
1411
+ * and click is a routine race, and `false` already describes it precisely.
1412
+ *
1413
+ * The three outcomes are kept DISTINCT all the way to the UI, because they
1414
+ * mean genuinely different things and only one of them is a failure:
1415
+ *
1416
+ * - `stopping` — the request was accepted. Nothing is evicted here: the SDK
1417
+ * emits the terminal `task_notification{status:"stopped"}` on the session's
1418
+ * own stream and {@link BackgroundRegistry} already evicts on it. This is
1419
+ * also the idempotent case — the CLI answers `not_found` / `not_running`
1420
+ * with a success, so a click racing a natural completion lands here.
1421
+ * - `gone` — no live session with that id. NOT a failure: the tasks died with
1422
+ * the session, so the user's intent is already satisfied. The caller drops
1423
+ * the row itself, because there is no stream left to notify on.
1424
+ * - `error` — the stop did NOT happen and the task is still running. Notably
1425
+ * a `monitor_mcp` task, which the CLI has no kill strategy for and rejects
1426
+ * with `unsupported_type`. Surfaced rather than reported as success.
1427
+ */
1428
+ async stopBackgroundTask(sessionId, taskId) {
1429
+ const fleet = this.fleet;
1430
+ if (!fleet)
1431
+ return { outcome: "gone" };
1432
+ try {
1433
+ const stopped = await fleet.stopTaskInSession(sessionId, taskId);
1434
+ return stopped ? { outcome: "stopping" } : { outcome: "gone" };
1435
+ }
1436
+ catch (err) {
1437
+ const message = err instanceof Error ? err.message : String(err);
1438
+ console.warn(`[herdctl] stopTaskInSession failed for ${sessionId}/${taskId}:`, err);
1439
+ return { outcome: "error", message };
1440
+ }
1441
+ }
1400
1442
  /**
1401
1443
  * End a managed chat SESSION outright, by session id (issue #731). Where
1402
1444
  * {@link cancel} ends the model turn — and on the session runtime deliberately
@@ -33,8 +33,18 @@ import type { FileEntry, FileKind } from "./project-types.js";
33
33
  * reachable there either. This is worth closing because "hidden in the listing"
34
34
  * should not be the only thing standing between an API and a transcript, not
35
35
  * because it grants anything new.
36
+ *
37
+ * **`hiddenSegments: "allow"`** turns the dot-directory half off, leaving only
38
+ * containment (issue #710). It exists for exactly one caller: `/git/file`, which
39
+ * serves a file from the project's WORKING directory and has already checked
40
+ * that `git status` reports that path as untracked. A git-verified allowlist is
41
+ * strictly stronger than this heuristic — git never names `.git` and never names
42
+ * an ignored path — and the heuristic is actively wrong out there, because a new
43
+ * `.github/workflows/ci.yml` is an ordinary Changes-tab row that a dot-directory
44
+ * rule refuses to render. Every other caller keeps the default. Pass it only
45
+ * with an independent allowlist in hand.
36
46
  */
37
- export declare function resolveInProject(root: string, slug: string, name: string): string;
47
+ export declare function resolveInProject(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): string;
38
48
  /**
39
49
  * List one level of a project directory (issue #259). `subpath` is a
40
50
  * project-relative directory ("" = the project root); the returned entries
@@ -46,16 +56,16 @@ export declare function resolveInProject(root: string, slug: string, name: strin
46
56
  * doesn't exist and `ProjectError("not_directory")` when `subpath` is a file —
47
57
  * the latter lets the caller fall back to rendering that file.
48
58
  */
49
- export declare function listFiles(root: string, slug: string, subpath?: string): Promise<FileEntry[]>;
59
+ export declare function listFiles(dir: string, subpath?: string): Promise<FileEntry[]>;
50
60
  /** Read a freeform file's contents as UTF-8 text (path-traversal guarded). */
51
- export declare function readProjectFile(root: string, slug: string, name: string): Promise<string>;
61
+ export declare function readProjectFile(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): Promise<string>;
52
62
  /**
53
63
  * Read a file's raw bytes + its MIME type (issue #61), for the binary/image
54
64
  * endpoint. Path-traversal guarded; throws ProjectError("not_found") if the
55
65
  * file is missing so the route can 404 cleanly. NOT decoded as text, so binary
56
- * (image) bytes survive intact.
66
+ * (image) bytes survive intact. See {@link resolveInProject} for `hiddenSegments`.
57
67
  */
58
- export declare function readFileBytes(root: string, slug: string, name: string): Promise<{
68
+ export declare function readFileBytes(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): Promise<{
59
69
  bytes: Buffer;
60
70
  mime: string;
61
71
  }>;
@@ -67,9 +77,10 @@ export declare function readFileBytes(root: string, slug: string, name: string):
67
77
  * For an IMAGE the raw bytes are NOT returned here (decoding binary as UTF-8
68
78
  * would mangle it): `content` is empty and the client fetches the bytes from
69
79
  * the raw endpoint. We still stat the file so a missing image 404s. Path-
70
- * traversal guarded; throws ProjectError("not_found") when missing.
80
+ * traversal guarded; throws ProjectError("not_found") when missing. See
81
+ * {@link resolveInProject} for `hiddenSegments`.
71
82
  */
72
- export declare function readFileWithKind(root: string, slug: string, name: string): Promise<{
83
+ export declare function readFileWithKind(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): Promise<{
73
84
  name: string;
74
85
  kind: FileKind;
75
86
  content: string;
@@ -3,31 +3,23 @@
3
3
  *
4
4
  * Extracted from projects.ts (issue #403): the path-traversal guard plus the
5
5
  * directory listing + file readers (text / raw bytes / kind-hinted). Pure free
6
- * functions taking `(root, slug, …)` — no `ProjectStore` state beyond the
7
- * projects root — so `ProjectStore` keeps thin delegate methods over these and
8
- * the public API is unchanged. `ProjectError` codes are preserved exactly.
6
+ * functions taking `(dir, …)` — no `ProjectStore` state at all — so
7
+ * `ProjectStore` keeps thin delegate methods over these and the public API is
8
+ * unchanged. `ProjectError` codes are preserved exactly.
9
+ *
10
+ * **`dir` is the project's CONTENT dir, not `projectsRoot/<slug>`** (issue #710).
11
+ * These used to take `(root, slug)` and re-derive the directory by joining, which
12
+ * was right only while a project's content lived under the projects root. A
13
+ * MANAGED project with a `path:` (issue #206) keeps its curated trio out at that
14
+ * path, so the join addressed a directory holding nothing but `project.yaml` and
15
+ * the whole Files tab came back empty. Taking the directory the caller already
16
+ * resolved removes the second copy of that resolution rather than teaching it a
17
+ * new case — see {@link import("./project-paths.js").contentDirFor}.
9
18
  */
10
19
  import { promises as fs } from "node:fs";
11
20
  import path from "node:path";
12
21
  import { ProjectError } from "./project-paths.js";
13
22
  import { fileKind, contentTypeFor } from "./project-mime.js";
14
- /**
15
- * The directory backing a workspace key — a second copy of
16
- * `ProjectStore.dirFor`'s resolution, kept in step by being the same one-liner.
17
- *
18
- * A key is a path relative to the projects root and the root workspace's key is
19
- * `""`, so `path.join` handles both with no branch. That matters historically:
20
- * the previous design DID need a branch here for a reserved slug, this copy was
21
- * missed, and every root file route 404'd until it was found in live QA. There
22
- * is no longer a branch to miss.
23
- *
24
- * The traversal guard below is unaffected: it compares against whatever this
25
- * returns, so at the root "inside the workspace dir" means "inside the projects
26
- * root" — exactly the scope of a keeper whose cwd is that directory.
27
- */
28
- function dirFor(root, key) {
29
- return path.join(root, key);
30
- }
31
23
  /**
32
24
  * Resolve a freeform file name to an absolute path inside the project dir,
33
25
  * rejecting path traversal AND traversal *through* a hidden (dot-prefixed)
@@ -62,13 +54,24 @@ function dirFor(root, key) {
62
54
  * reachable there either. This is worth closing because "hidden in the listing"
63
55
  * should not be the only thing standing between an API and a transcript, not
64
56
  * because it grants anything new.
57
+ *
58
+ * **`hiddenSegments: "allow"`** turns the dot-directory half off, leaving only
59
+ * containment (issue #710). It exists for exactly one caller: `/git/file`, which
60
+ * serves a file from the project's WORKING directory and has already checked
61
+ * that `git status` reports that path as untracked. A git-verified allowlist is
62
+ * strictly stronger than this heuristic — git never names `.git` and never names
63
+ * an ignored path — and the heuristic is actively wrong out there, because a new
64
+ * `.github/workflows/ci.yml` is an ordinary Changes-tab row that a dot-directory
65
+ * rule refuses to render. Every other caller keeps the default. Pass it only
66
+ * with an independent allowlist in hand.
65
67
  */
66
- export function resolveInProject(root, slug, name) {
67
- const dir = dirFor(root, slug);
68
+ export function resolveInProject(dir, name, hiddenSegments = "refuse") {
68
69
  const resolved = path.resolve(dir, name);
69
70
  if (resolved !== dir && !resolved.startsWith(dir + path.sep)) {
70
71
  throw new ProjectError("Invalid file path", "invalid");
71
72
  }
73
+ if (hiddenSegments === "allow")
74
+ return resolved;
72
75
  // Check the RESOLVED path's segments relative to the project dir, not the
73
76
  // caller's raw string: `a/./.git/config` and `a/../.git/config` both normalise
74
77
  // to a hidden directory segment the raw string doesn't literally contain. The
@@ -100,11 +103,11 @@ function isHidden(segment) {
100
103
  * doesn't exist and `ProjectError("not_directory")` when `subpath` is a file —
101
104
  * the latter lets the caller fall back to rendering that file.
102
105
  */
103
- export async function listFiles(root, slug, subpath = "") {
104
- const target = resolveInProject(root, slug, subpath);
106
+ export async function listFiles(dir, subpath = "") {
107
+ const target = resolveInProject(dir, subpath);
105
108
  // A LISTING target is a directory, so unlike a read the leaf gets no pass:
106
109
  // `?path=.chats` is exactly how every transcript filename was enumerable.
107
- const leaf = relSegments(dirFor(root, slug), target).at(-1);
110
+ const leaf = relSegments(dir, target).at(-1);
108
111
  if (leaf && isHidden(leaf)) {
109
112
  throw new ProjectError("Invalid file path", "invalid");
110
113
  }
@@ -126,17 +129,17 @@ export async function listFiles(root, slug, subpath = "") {
126
129
  .sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1);
127
130
  }
128
131
  /** Read a freeform file's contents as UTF-8 text (path-traversal guarded). */
129
- export async function readProjectFile(root, slug, name) {
130
- return fs.readFile(resolveInProject(root, slug, name), "utf8");
132
+ export async function readProjectFile(dir, name, hiddenSegments = "refuse") {
133
+ return fs.readFile(resolveInProject(dir, name, hiddenSegments), "utf8");
131
134
  }
132
135
  /**
133
136
  * Read a file's raw bytes + its MIME type (issue #61), for the binary/image
134
137
  * endpoint. Path-traversal guarded; throws ProjectError("not_found") if the
135
138
  * file is missing so the route can 404 cleanly. NOT decoded as text, so binary
136
- * (image) bytes survive intact.
139
+ * (image) bytes survive intact. See {@link resolveInProject} for `hiddenSegments`.
137
140
  */
138
- export async function readFileBytes(root, slug, name) {
139
- const resolved = resolveInProject(root, slug, name);
141
+ export async function readFileBytes(dir, name, hiddenSegments = "refuse") {
142
+ const resolved = resolveInProject(dir, name, hiddenSegments);
140
143
  try {
141
144
  const bytes = await fs.readFile(resolved);
142
145
  return { bytes, mime: contentTypeFor(name) };
@@ -156,14 +159,15 @@ export async function readFileBytes(root, slug, name) {
156
159
  * For an IMAGE the raw bytes are NOT returned here (decoding binary as UTF-8
157
160
  * would mangle it): `content` is empty and the client fetches the bytes from
158
161
  * the raw endpoint. We still stat the file so a missing image 404s. Path-
159
- * traversal guarded; throws ProjectError("not_found") when missing.
162
+ * traversal guarded; throws ProjectError("not_found") when missing. See
163
+ * {@link resolveInProject} for `hiddenSegments`.
160
164
  */
161
- export async function readFileWithKind(root, slug, name) {
165
+ export async function readFileWithKind(dir, name, hiddenSegments = "refuse") {
162
166
  const kind = fileKind(name);
163
167
  if (kind === "image") {
164
168
  // Existence check only — the bytes go over the raw endpoint.
165
169
  try {
166
- await fs.stat(resolveInProject(root, slug, name));
170
+ await fs.stat(resolveInProject(dir, name, hiddenSegments));
167
171
  }
168
172
  catch (err) {
169
173
  if (err instanceof ProjectError)
@@ -177,7 +181,7 @@ export async function readFileWithKind(root, slug, name) {
177
181
  }
178
182
  let content;
179
183
  try {
180
- content = await readProjectFile(root, slug, name);
184
+ content = await readProjectFile(dir, name, hiddenSegments);
181
185
  }
182
186
  catch (err) {
183
187
  if (err instanceof ProjectError)
@@ -311,7 +311,7 @@ export declare class ProjectStore {
311
311
  */
312
312
  writeChangelog(slug: string, body: string): Promise<void>;
313
313
  /**
314
- * List one level of a project directory (issue #259). See
314
+ * List one level of a project's content directory (issue #259). See
315
315
  * {@link import("./project-files.js").listFiles}.
316
316
  */
317
317
  listFiles(slug: string, subpath?: string): Promise<FileEntry[]>;
@@ -1066,32 +1066,39 @@ export class ProjectStore {
1066
1066
  await fs.writeFile(file, `${title}\n\n${withoutTitle.trim()}\n`, "utf8");
1067
1067
  }
1068
1068
  // --- freeform file serving (delegated to project-files.ts, issue #403) ---
1069
- // Thin wrappers over the pure `(root, slug, …)` helpers so `ProjectStore`'s
1070
- // public file-read API (and its ProjectError codes) is unchanged.
1069
+ // Thin wrappers over the pure `(dir, …)` helpers so `ProjectStore`'s public
1070
+ // file-read API (and its ProjectError codes) is unchanged.
1071
+ //
1072
+ // Every one of them browses the project's CONTENT dir (issue #710). That is
1073
+ // the metadata dir for a notebook and for any UNMANAGED project — unchanged —
1074
+ // but for a MANAGED project with a `path:` (issue #206) the curated trio lives
1075
+ // out at that path, and joining `root + slug` addressed a directory holding
1076
+ // only `project.yaml`, so the Files tab listed nothing and every note 404'd.
1077
+ // `contentDirOf` is the single resolution both this and the sweeper use.
1071
1078
  /**
1072
- * List one level of a project directory (issue #259). See
1079
+ * List one level of a project's content directory (issue #259). See
1073
1080
  * {@link import("./project-files.js").listFiles}.
1074
1081
  */
1075
1082
  async listFiles(slug, subpath = "") {
1076
- return projectFiles.listFiles(this.root, slug, subpath);
1083
+ return projectFiles.listFiles(await this.contentDirOf(slug), subpath);
1077
1084
  }
1078
1085
  /** Read a freeform file's contents as UTF-8 text (path-traversal guarded). */
1079
1086
  async readFile(slug, name) {
1080
- return projectFiles.readProjectFile(this.root, slug, name);
1087
+ return projectFiles.readProjectFile(await this.contentDirOf(slug), name);
1081
1088
  }
1082
1089
  /**
1083
1090
  * Read a file's raw bytes + its MIME type (issue #61), for the binary/image
1084
1091
  * endpoint. See {@link import("./project-files.js").readFileBytes}.
1085
1092
  */
1086
1093
  async readFileBytes(slug, name) {
1087
- return projectFiles.readFileBytes(this.root, slug, name);
1094
+ return projectFiles.readFileBytes(await this.contentDirOf(slug), name);
1088
1095
  }
1089
1096
  /**
1090
1097
  * Read a file plus a render-kind hint derived from its extension (issues #3 /
1091
1098
  * #61). See {@link import("./project-files.js").readFileWithKind}.
1092
1099
  */
1093
1100
  async readFileWithKind(slug, name) {
1094
- return projectFiles.readFileWithKind(this.root, slug, name);
1101
+ return projectFiles.readFileWithKind(await this.contentDirOf(slug), name);
1095
1102
  }
1096
1103
  // --- internals ---------------------------------------------------------
1097
1104
  /**
@@ -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: {
@@ -73,7 +73,7 @@ export function registerDiscoverRoutes(app, ctx) {
73
73
  schema: {
74
74
  tags: ["System"],
75
75
  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 }`.",
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`. 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 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 }`.",
77
77
  querystring: {
78
78
  type: "object",
79
79
  properties: {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Git routes: the fleet-level remote + GitHub device-flow auth, plus the
3
- * per-project working-tree status / diff / commit surface (#258). Push is a
4
- * separate explicit action from a project commit.
3
+ * per-project working-tree status / diff / commit / remote / push surface
4
+ * (#258, #710). Push is a separate explicit action from a project commit.
5
5
  */
6
6
  import type { FastifyInstance } from "fastify";
7
7
  import type { RouteCtx } from "../route-context.js";
@@ -1,4 +1,6 @@
1
1
  import { sendProjectError } from "../route-errors.js";
2
+ import { ProjectError } from "../project-paths.js";
3
+ import { readFileWithKind, readFileBytes } from "../project-files.js";
2
4
  export function registerGitRoutes(app, ctx) {
3
5
  const { git, githubAuth } = ctx;
4
6
  // --- git (backing store): fleet-level remote + connection state --------
@@ -118,7 +120,7 @@ export function registerGitRoutes(app, ctx) {
118
120
  * and per project) — see `workspace-mount.ts`. Paths are relative to the mount.
119
121
  */
120
122
  export function registerGitWorkspaceRoutes(app, ctx) {
121
- const { git, projects } = ctx;
123
+ const { git, projects, githubAuth } = ctx;
122
124
  // --- git (backing-store capability, phase 1: read surface) -------------
123
125
  // Uncommitted changes confined to this project's subtree. Returns
124
126
  // `{ repo: false }` when the projects dir isn't a git working tree, so the
@@ -236,4 +238,134 @@ export function registerGitWorkspaceRoutes(app, ctx) {
236
238
  return sendProjectError(reply, err);
237
239
  }
238
240
  });
241
+ // This project's own remote + ahead/behind, with the (genuinely fleet-level)
242
+ // GitHub connection state folded in so the Changes header is one fetch (#710).
243
+ // Same payload shape as `GET /api/git`, which now differs only in WHICH repo it
244
+ // describes: that one always speaks for the backing store, this one for the
245
+ // directory the project actually works in.
246
+ app.get("/git/remote", {
247
+ schema: {
248
+ tags: ["Git"],
249
+ summary: "Get a project's git remote and GitHub connection state",
250
+ description: "Returns the remote details for the project's OWN working directory (a linked checkout or worktree has its own origin, branch and ahead/behind — issue #710) merged with a `github` object describing the fleet-level GitHub device-flow connection status.",
251
+ params: {
252
+ type: "object",
253
+ properties: {
254
+ slug: { type: "string", description: "The project slug." },
255
+ },
256
+ required: ["slug"],
257
+ },
258
+ response: {
259
+ 200: {
260
+ description: "Object containing the remote configuration fields plus a nested `github` object with connection state.",
261
+ type: "object",
262
+ additionalProperties: true,
263
+ },
264
+ },
265
+ },
266
+ }, async (req, reply) => {
267
+ try {
268
+ const project = await projects.get(req.params.slug);
269
+ const [remote, github] = await Promise.all([
270
+ git.remote(project.workingDir),
271
+ githubAuth.status(),
272
+ ]);
273
+ return { ...remote, github };
274
+ }
275
+ catch (err) {
276
+ return sendProjectError(reply, err);
277
+ }
278
+ });
279
+ // Push THIS project's working directory to its origin (#710). The fleet-level
280
+ // `POST /api/git/push` still pushes the backing store; for a notebook project
281
+ // the two are the same repo, and for a linked one they are emphatically not —
282
+ // the Changes header's Push button used to push Paddock's notes repo while
283
+ // showing the user's worktree branch beside it.
284
+ app.post("/git/push", {
285
+ schema: {
286
+ tags: ["Git"],
287
+ summary: "Push a project's working directory to its remote",
288
+ description: "Pushes the current branch of the project's own working directory to its `origin`, setting upstream on first push. Returns an object describing the push result.",
289
+ params: {
290
+ type: "object",
291
+ properties: {
292
+ slug: { type: "string", description: "The project slug." },
293
+ },
294
+ required: ["slug"],
295
+ },
296
+ response: {
297
+ 200: {
298
+ description: "Object describing the outcome of the push operation.",
299
+ type: "object",
300
+ additionalProperties: true,
301
+ },
302
+ },
303
+ },
304
+ }, async (req, reply) => {
305
+ try {
306
+ const project = await projects.get(req.params.slug);
307
+ return await git.push(project.workingDir);
308
+ }
309
+ catch (err) {
310
+ return sendProjectError(reply, err);
311
+ }
312
+ });
313
+ // An UNTRACKED file's content, read from the project's WORKING directory
314
+ // (#710). The Changes tab shows a new file's content in place of a diff, and
315
+ // used to fetch it from `/files/:name` — the metadata-dir surface, which for a
316
+ // repo-backed or linked project is a different directory entirely, so every
317
+ // new file in the pane rendered "File not found".
318
+ //
319
+ // The gate is `isUntrackedFile`, i.e. git's own answer, NOT the dot-segment
320
+ // guard `/files/:name` uses — see {@link GitService.isUntrackedFile} for why a
321
+ // path-shape rule is wrong in both directions once the target is a work tree.
322
+ // A path git does not report as untracked 404s, so the servable set is exactly
323
+ // the set the pane lists.
324
+ app.get("/git/file", {
325
+ schema: {
326
+ tags: ["Git"],
327
+ summary: "Get an untracked file's content from the project's working directory",
328
+ description: "Returns the content of a file git reports as UNTRACKED in the project's working directory — what the Changes tab renders in place of a diff for a brand-new file. `?path=` is the project-relative path; anything git does not currently report as untracked 404s. With `?raw=1` it streams raw bytes with the correct Content-Type (sandbox CSP + nosniff + inline disposition), how the pane loads a new image.",
329
+ params: {
330
+ type: "object",
331
+ properties: {
332
+ slug: { type: "string", description: "The project slug." },
333
+ },
334
+ required: ["slug"],
335
+ },
336
+ querystring: {
337
+ type: "object",
338
+ additionalProperties: true,
339
+ properties: {
340
+ path: { type: "string", description: "Project-relative path of the untracked file." },
341
+ raw: {
342
+ type: "string",
343
+ description: "Set to a truthy value (e.g. `1`) to stream raw bytes instead of JSON.",
344
+ },
345
+ },
346
+ },
347
+ },
348
+ }, async (req, reply) => {
349
+ try {
350
+ const project = await projects.get(req.params.slug);
351
+ const name = req.query.path ?? "";
352
+ if (!(await git.isUntrackedFile(project.workingDir, name))) {
353
+ throw new ProjectError(`File not found: ${name}`, "not_found");
354
+ }
355
+ if (req.query.raw !== undefined && req.query.raw !== "0" && req.query.raw !== "false") {
356
+ const { bytes, mime } = await readFileBytes(project.workingDir, name, "allow");
357
+ return reply
358
+ .header("content-type", mime)
359
+ .header("content-disposition", "inline")
360
+ .header("x-content-type-options", "nosniff")
361
+ .header("content-security-policy", "sandbox; default-src 'none'")
362
+ .header("cache-control", "private, max-age=60")
363
+ .send(bytes);
364
+ }
365
+ return await readFileWithKind(project.workingDir, name, "allow");
366
+ }
367
+ catch (err) {
368
+ return sendProjectError(reply, err);
369
+ }
370
+ });
239
371
  }