@edspencer/paddock 0.0.1 → 0.57.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.
- package/README.md +69 -0
- package/install-notice.mjs +29 -0
- package/package.json +58 -5
- package/packages/server/dist/adoptable.d.ts +127 -0
- package/packages/server/dist/adoptable.js +315 -0
- package/packages/server/dist/app.d.ts +67 -0
- package/packages/server/dist/app.js +325 -0
- package/packages/server/dist/archive.d.ts +42 -0
- package/packages/server/dist/archive.js +125 -0
- package/packages/server/dist/attachments-config.d.ts +114 -0
- package/packages/server/dist/attachments-config.js +165 -0
- package/packages/server/dist/attachments-hint.d.ts +53 -0
- package/packages/server/dist/attachments-hint.js +140 -0
- package/packages/server/dist/attachments.d.ts +33 -0
- package/packages/server/dist/attachments.js +117 -0
- package/packages/server/dist/auth.d.ts +84 -0
- package/packages/server/dist/auth.js +247 -0
- package/packages/server/dist/background.d.ts +36 -0
- package/packages/server/dist/background.js +137 -0
- package/packages/server/dist/bind-safety.d.ts +56 -0
- package/packages/server/dist/bind-safety.js +51 -0
- package/packages/server/dist/brand.d.ts +44 -0
- package/packages/server/dist/brand.js +88 -0
- package/packages/server/dist/chat-dto.d.ts +210 -0
- package/packages/server/dist/chat-dto.js +308 -0
- package/packages/server/dist/cli/import-chats.d.ts +21 -0
- package/packages/server/dist/cli/import-chats.js +197 -0
- package/packages/server/dist/cli/paddock.d.ts +2 -0
- package/packages/server/dist/cli/paddock.js +247 -0
- package/packages/server/dist/concurrency.d.ts +57 -0
- package/packages/server/dist/concurrency.js +71 -0
- package/packages/server/dist/config.d.ts +538 -0
- package/packages/server/dist/config.js +625 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -0
- package/packages/server/dist/environment-prompt.d.ts +52 -0
- package/packages/server/dist/environment-prompt.js +61 -0
- package/packages/server/dist/event-bus.d.ts +87 -0
- package/packages/server/dist/event-bus.js +48 -0
- package/packages/server/dist/git.d.ts +128 -0
- package/packages/server/dist/git.js +446 -0
- package/packages/server/dist/github-auth.d.ts +44 -0
- package/packages/server/dist/github-auth.js +169 -0
- package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
- package/packages/server/dist/herdctl-agent-config.js +331 -0
- package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
- package/packages/server/dist/herdctl-agent-names.js +228 -0
- package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
- package/packages/server/dist/herdctl-jobs-index.js +225 -0
- package/packages/server/dist/herdctl-jobs.d.ts +98 -0
- package/packages/server/dist/herdctl-jobs.js +225 -0
- package/packages/server/dist/herdctl.d.ts +623 -0
- package/packages/server/dist/herdctl.js +1279 -0
- package/packages/server/dist/hook-config.d.ts +217 -0
- package/packages/server/dist/hook-config.js +224 -0
- package/packages/server/dist/http-bytes.d.ts +24 -0
- package/packages/server/dist/http-bytes.js +52 -0
- package/packages/server/dist/index.d.ts +1 -0
- package/packages/server/dist/index.js +14 -0
- package/packages/server/dist/instance-config.d.ts +143 -0
- package/packages/server/dist/instance-config.js +385 -0
- package/packages/server/dist/localcommand.d.ts +23 -0
- package/packages/server/dist/localcommand.js +140 -0
- package/packages/server/dist/management-auth.d.ts +99 -0
- package/packages/server/dist/management-auth.js +165 -0
- package/packages/server/dist/management-config.d.ts +164 -0
- package/packages/server/dist/management-config.js +286 -0
- package/packages/server/dist/management-mcp-server.d.ts +20 -0
- package/packages/server/dist/management-mcp-server.js +95 -0
- package/packages/server/dist/management-metadata.d.ts +67 -0
- package/packages/server/dist/management-metadata.js +56 -0
- package/packages/server/dist/management-ops.d.ts +110 -0
- package/packages/server/dist/management-ops.js +449 -0
- package/packages/server/dist/management-policy.d.ts +172 -0
- package/packages/server/dist/management-policy.js +259 -0
- package/packages/server/dist/message-provenance.d.ts +112 -0
- package/packages/server/dist/message-provenance.js +260 -0
- package/packages/server/dist/models.d.ts +164 -0
- package/packages/server/dist/models.js +208 -0
- package/packages/server/dist/openapi.d.ts +66 -0
- package/packages/server/dist/openapi.js +183 -0
- package/packages/server/dist/parent-detach.d.ts +30 -0
- package/packages/server/dist/parent-detach.js +109 -0
- package/packages/server/dist/preload.d.ts +43 -0
- package/packages/server/dist/preload.js +55 -0
- package/packages/server/dist/project-files.d.ts +76 -0
- package/packages/server/dist/project-files.js +191 -0
- package/packages/server/dist/project-mime.d.ts +35 -0
- package/packages/server/dist/project-mime.js +55 -0
- package/packages/server/dist/project-paths.d.ts +88 -0
- package/packages/server/dist/project-paths.js +126 -0
- package/packages/server/dist/project-types.d.ts +316 -0
- package/packages/server/dist/project-types.js +48 -0
- package/packages/server/dist/projects.d.ts +202 -0
- package/packages/server/dist/projects.js +894 -0
- package/packages/server/dist/queued-message.d.ts +35 -0
- package/packages/server/dist/queued-message.js +116 -0
- package/packages/server/dist/read-state.d.ts +46 -0
- package/packages/server/dist/read-state.js +139 -0
- package/packages/server/dist/recovery-config.d.ts +111 -0
- package/packages/server/dist/recovery-config.js +105 -0
- package/packages/server/dist/recovery.d.ts +262 -0
- package/packages/server/dist/recovery.js +490 -0
- package/packages/server/dist/resume-drain.d.ts +122 -0
- package/packages/server/dist/resume-drain.js +158 -0
- package/packages/server/dist/route-context.d.ts +168 -0
- package/packages/server/dist/route-context.js +39 -0
- package/packages/server/dist/route-errors.d.ts +8 -0
- package/packages/server/dist/route-errors.js +13 -0
- package/packages/server/dist/routes/chats.d.ts +12 -0
- package/packages/server/dist/routes/chats.js +1406 -0
- package/packages/server/dist/routes/git.d.ts +13 -0
- package/packages/server/dist/routes/git.js +236 -0
- package/packages/server/dist/routes/mcp.d.ts +83 -0
- package/packages/server/dist/routes/mcp.js +250 -0
- package/packages/server/dist/routes/meta.d.ts +15 -0
- package/packages/server/dist/routes/meta.js +447 -0
- package/packages/server/dist/routes/projects.d.ts +25 -0
- package/packages/server/dist/routes/projects.js +736 -0
- package/packages/server/dist/routes/triggers.d.ts +21 -0
- package/packages/server/dist/routes/triggers.js +324 -0
- package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
- package/packages/server/dist/routes/workspace-mount.js +32 -0
- package/packages/server/dist/routes.d.ts +26 -0
- package/packages/server/dist/routes.js +29 -0
- package/packages/server/dist/run-provenance.d.ts +109 -0
- package/packages/server/dist/run-provenance.js +206 -0
- package/packages/server/dist/runs.d.ts +92 -0
- package/packages/server/dist/runs.js +72 -0
- package/packages/server/dist/schedule-config.d.ts +68 -0
- package/packages/server/dist/schedule-config.js +75 -0
- package/packages/server/dist/schedule-session.d.ts +34 -0
- package/packages/server/dist/schedule-session.js +120 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
- package/packages/server/dist/self-mcp-descriptions.js +145 -0
- package/packages/server/dist/self-mcp-read.d.ts +15 -0
- package/packages/server/dist/self-mcp-read.js +97 -0
- package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
- package/packages/server/dist/self-mcp-triggers.js +144 -0
- package/packages/server/dist/self-mcp-types.d.ts +248 -0
- package/packages/server/dist/self-mcp-types.js +10 -0
- package/packages/server/dist/self-mcp-util.d.ts +73 -0
- package/packages/server/dist/self-mcp-util.js +157 -0
- package/packages/server/dist/self-mcp-write.d.ts +35 -0
- package/packages/server/dist/self-mcp-write.js +215 -0
- package/packages/server/dist/self-mcp.d.ts +95 -0
- package/packages/server/dist/self-mcp.js +448 -0
- package/packages/server/dist/send-file-mcp.d.ts +46 -0
- package/packages/server/dist/send-file-mcp.js +281 -0
- package/packages/server/dist/session-hub.d.ts +186 -0
- package/packages/server/dist/session-hub.js +291 -0
- package/packages/server/dist/spawn-capability.d.ts +99 -0
- package/packages/server/dist/spawn-capability.js +106 -0
- package/packages/server/dist/star.d.ts +26 -0
- package/packages/server/dist/star.js +94 -0
- package/packages/server/dist/start.d.ts +1 -0
- package/packages/server/dist/start.js +21 -0
- package/packages/server/dist/subagents.d.ts +337 -0
- package/packages/server/dist/subagents.js +626 -0
- package/packages/server/dist/sweep.d.ts +122 -0
- package/packages/server/dist/sweep.js +696 -0
- package/packages/server/dist/tooldetails.d.ts +33 -0
- package/packages/server/dist/tooldetails.js +466 -0
- package/packages/server/dist/transcribe.d.ts +69 -0
- package/packages/server/dist/transcribe.js +119 -0
- package/packages/server/dist/transcripts.d.ts +42 -0
- package/packages/server/dist/transcripts.js +164 -0
- package/packages/server/dist/trigger-config.d.ts +278 -0
- package/packages/server/dist/trigger-config.js +417 -0
- package/packages/server/dist/trigger-runtime.d.ts +94 -0
- package/packages/server/dist/trigger-runtime.js +82 -0
- package/packages/server/dist/trigger-session.d.ts +34 -0
- package/packages/server/dist/trigger-session.js +122 -0
- package/packages/server/dist/triggers.d.ts +83 -0
- package/packages/server/dist/triggers.js +124 -0
- package/packages/server/dist/trusted-proxy.d.ts +57 -0
- package/packages/server/dist/trusted-proxy.js +157 -0
- package/packages/server/dist/turn-notice.d.ts +154 -0
- package/packages/server/dist/turn-notice.js +271 -0
- package/packages/server/dist/unread.d.ts +36 -0
- package/packages/server/dist/unread.js +123 -0
- package/packages/server/dist/usage.d.ts +66 -0
- package/packages/server/dist/usage.js +243 -0
- package/packages/server/dist/wake-injection.d.ts +156 -0
- package/packages/server/dist/wake-injection.js +136 -0
- package/packages/server/dist/whisper-local.d.ts +9 -0
- package/packages/server/dist/whisper-local.js +122 -0
- package/packages/server/dist/ws-context.d.ts +158 -0
- package/packages/server/dist/ws-context.js +1 -0
- package/packages/server/dist/ws-protocol.d.ts +423 -0
- package/packages/server/dist/ws-protocol.js +251 -0
- package/packages/server/dist/ws-self-mcp.d.ts +52 -0
- package/packages/server/dist/ws-self-mcp.js +28 -0
- package/packages/server/dist/ws-triggers.d.ts +29 -0
- package/packages/server/dist/ws-triggers.js +261 -0
- package/packages/server/dist/ws-turn.d.ts +86 -0
- package/packages/server/dist/ws-turn.js +751 -0
- package/packages/server/dist/ws.d.ts +62 -0
- package/packages/server/dist/ws.js +1024 -0
- package/packages/server/package.json +6 -0
- package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
- package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
- package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
- package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
- package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
- package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
- package/packages/web/dist/assets/css-DazXZka4.js +2 -0
- package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
- package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
- package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
- package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
- package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
- package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
- package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
- package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
- package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
- package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
- package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
- package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
- package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
- package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
- package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
- package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
- package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
- package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
- package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
- package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
- package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
- package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
- package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
- package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
- package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
- package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/packages/web/dist/icons/apple-touch-icon.png +0 -0
- package/packages/web/dist/icons/favicon-16.png +0 -0
- package/packages/web/dist/icons/favicon-32.png +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/maskable-512.png +0 -0
- package/packages/web/dist/index.html +56 -0
- package/packages/web/dist/manifest.webmanifest +32 -0
- package/packages/web/dist/sw.js +139 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ParentDetachStore — a Paddock-side sidecar recording which chats the user has
|
|
3
|
+
* explicitly DETACHED from their parent (#508).
|
|
4
|
+
*
|
|
5
|
+
* The nested chat list (#485) draws a chat under its parent, and that edge is
|
|
6
|
+
* resolved in two tiers (see `makeParentResolver`): the RECORDED
|
|
7
|
+
* `RunProvenance.parentSessionId`, and — for chats created before that field
|
|
8
|
+
* existed — an edge INFERRED from who injected the kickoff message. Detach
|
|
9
|
+
* therefore cannot be implemented by clearing the recorded edge: the inference
|
|
10
|
+
* tier would simply re-derive the old parent on the next page load. It needs an
|
|
11
|
+
* explicit, persisted "this chat is a root, whatever the tiers say" sentinel that
|
|
12
|
+
* is checked AHEAD of both.
|
|
13
|
+
*
|
|
14
|
+
* That sentinel lives here rather than on {@link RunProvenance}, which #508
|
|
15
|
+
* suggested. Provenance is documented — and implemented — as write-once creation
|
|
16
|
+
* metadata: "stamped once, at creation, and NEVER clobbered by a later turn".
|
|
17
|
+
* Detach is a user decision made long after creation, and a chat with no recorded
|
|
18
|
+
* provenance at all (every pre-#485 chat, i.e. exactly the population whose parent
|
|
19
|
+
* is inferred) would have needed an `origin`/`depth` fabricated for it just to
|
|
20
|
+
* carry the flag — which would then show a "spawned" badge the chat never earned.
|
|
21
|
+
* A separate flag keeps provenance honest and detach cheap.
|
|
22
|
+
*
|
|
23
|
+
* Shape/pattern is a near-verbatim copy of {@link ArchiveStore} — a tiny JSON
|
|
24
|
+
* array sidecar in the data dir, lazy-loaded, write-through, serialised,
|
|
25
|
+
* corruption-tolerant. Keyed by `<agent>\0<sessionId>` (NUL-separated) like
|
|
26
|
+
* archive/star, and SHARED rather than per-user: detaching restructures the list
|
|
27
|
+
* everyone sees, so it is not personal state the way read/unread is.
|
|
28
|
+
*/
|
|
29
|
+
import { promises as fs } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
const STATE_FILE = "parent-detach.json";
|
|
32
|
+
/** Separator in a storage key; a NUL can't occur in an agent name or a UUID. */
|
|
33
|
+
const KEY_SEP = "\u0000";
|
|
34
|
+
/** Compose the storage key for an (agent, session) pair. */
|
|
35
|
+
function keyOf(agent, sessionId) {
|
|
36
|
+
return `${agent}${KEY_SEP}${sessionId}`;
|
|
37
|
+
}
|
|
38
|
+
export class ParentDetachStore {
|
|
39
|
+
stateFile;
|
|
40
|
+
/** In-memory set of detached keys (loaded once, written through on change). */
|
|
41
|
+
detached = null;
|
|
42
|
+
/**
|
|
43
|
+
* The in-flight load, cached so concurrent first-callers share ONE read — the
|
|
44
|
+
* same lost-update guard {@link ArchiveStore} carries.
|
|
45
|
+
*/
|
|
46
|
+
loadPromise = null;
|
|
47
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
48
|
+
writing = Promise.resolve();
|
|
49
|
+
constructor(dataDir) {
|
|
50
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
51
|
+
}
|
|
52
|
+
/** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
53
|
+
ensureLoaded() {
|
|
54
|
+
if (this.detached)
|
|
55
|
+
return Promise.resolve(this.detached);
|
|
56
|
+
if (!this.loadPromise) {
|
|
57
|
+
this.loadPromise = (async () => {
|
|
58
|
+
let keys = [];
|
|
59
|
+
try {
|
|
60
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (Array.isArray(parsed))
|
|
63
|
+
keys = parsed.filter((k) => typeof k === "string");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* missing or unreadable — start empty */
|
|
67
|
+
}
|
|
68
|
+
this.detached = new Set(keys);
|
|
69
|
+
return this.detached;
|
|
70
|
+
})();
|
|
71
|
+
}
|
|
72
|
+
return this.loadPromise;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Has this chat been detached from its parent? Non-throwing: any load error
|
|
76
|
+
* reads as false, so a corrupt sidecar degrades to "nesting as recorded"
|
|
77
|
+
* rather than flattening the whole list.
|
|
78
|
+
*/
|
|
79
|
+
async isDetached(agent, sessionId) {
|
|
80
|
+
const set = await this.ensureLoaded().catch(() => new Set());
|
|
81
|
+
return set.has(keyOf(agent, sessionId));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Set (or clear) a chat's detached flag, persisting the change. Idempotent;
|
|
85
|
+
* returns whether the flag actually CHANGED. Clearing it re-exposes whatever
|
|
86
|
+
* the two resolver tiers say — which is what makes re-attach free: nothing was
|
|
87
|
+
* ever destroyed, only overridden.
|
|
88
|
+
*/
|
|
89
|
+
async setDetached(agent, sessionId, detached) {
|
|
90
|
+
const set = await this.ensureLoaded();
|
|
91
|
+
const key = keyOf(agent, sessionId);
|
|
92
|
+
if (detached === set.has(key))
|
|
93
|
+
return false; // no-op — avoid a needless write
|
|
94
|
+
if (detached)
|
|
95
|
+
set.add(key);
|
|
96
|
+
else
|
|
97
|
+
set.delete(key);
|
|
98
|
+
await this.persist(set);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
/** Write-through, serialised so overlapping toggles can't corrupt the file. */
|
|
102
|
+
persist(set) {
|
|
103
|
+
this.writing = this.writing.then(async () => {
|
|
104
|
+
const json = JSON.stringify([...set], null, 2);
|
|
105
|
+
await fs.writeFile(this.stateFile, json, "utf8").catch(() => undefined);
|
|
106
|
+
});
|
|
107
|
+
return this.writing;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project-context "preload" wrapper (issue #1) — single-sourced here so the
|
|
3
|
+
* WS layer that BUILDS it and the chat-list that STRIPS it for display can't
|
|
4
|
+
* drift (issue #62).
|
|
5
|
+
*
|
|
6
|
+
* On the first turn of a new project chat with preload enabled, the server
|
|
7
|
+
* prepends the project's curated context — OVERVIEW.md (current state) plus
|
|
8
|
+
* CHANGELOG.md (cross-session history, issue #188) — to the user's message as a
|
|
9
|
+
* delimited block:
|
|
10
|
+
*
|
|
11
|
+
* <project-context>
|
|
12
|
+
* …OVERVIEW.md…
|
|
13
|
+
*
|
|
14
|
+
* …CHANGELOG.md…
|
|
15
|
+
* </project-context>
|
|
16
|
+
*
|
|
17
|
+
* My request:
|
|
18
|
+
* <the user's actual message>
|
|
19
|
+
*
|
|
20
|
+
* That block becomes the session's first user message, so Claude Code's derived
|
|
21
|
+
* preview (and thus the sidebar name) is the context text, not the request —
|
|
22
|
+
* exactly the #62 complaint. {@link stripPreloadWrapper} recovers the request.
|
|
23
|
+
*/
|
|
24
|
+
export declare const PRELOAD_CONTEXT_OPEN = "<project-context>";
|
|
25
|
+
/** The literal boundary between the context block and the user's real request. */
|
|
26
|
+
export declare const PRELOAD_REQUEST_MARKER = "</project-context>\n\nMy request:\n";
|
|
27
|
+
/**
|
|
28
|
+
* Assemble the preload context body from the project's curated docs (issue
|
|
29
|
+
* #188). OVERVIEW.md carries current state; CHANGELOG.md carries the
|
|
30
|
+
* cross-session narrative — both reach the chat so the history is no longer
|
|
31
|
+
* write-only. Each doc self-labels via its own `#` heading, so they are joined
|
|
32
|
+
* with a blank line; empty docs are dropped.
|
|
33
|
+
*/
|
|
34
|
+
export declare function composePreloadContext(overview: string, changelog: string): string;
|
|
35
|
+
/** Build the preload-wrapped prompt for a new project chat's first turn. */
|
|
36
|
+
export declare function wrapPreload(context: string, message: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Recover the user's real request from a (possibly) preload-wrapped message. If
|
|
39
|
+
* `text` isn't the wrapper it's returned unchanged; if it is, everything after
|
|
40
|
+
* the `My request:` marker is returned. Returns the input unchanged when the
|
|
41
|
+
* marker is absent (e.g. the wrapper was truncated before it).
|
|
42
|
+
*/
|
|
43
|
+
export declare function stripPreloadWrapper(text: string): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project-context "preload" wrapper (issue #1) — single-sourced here so the
|
|
3
|
+
* WS layer that BUILDS it and the chat-list that STRIPS it for display can't
|
|
4
|
+
* drift (issue #62).
|
|
5
|
+
*
|
|
6
|
+
* On the first turn of a new project chat with preload enabled, the server
|
|
7
|
+
* prepends the project's curated context — OVERVIEW.md (current state) plus
|
|
8
|
+
* CHANGELOG.md (cross-session history, issue #188) — to the user's message as a
|
|
9
|
+
* delimited block:
|
|
10
|
+
*
|
|
11
|
+
* <project-context>
|
|
12
|
+
* …OVERVIEW.md…
|
|
13
|
+
*
|
|
14
|
+
* …CHANGELOG.md…
|
|
15
|
+
* </project-context>
|
|
16
|
+
*
|
|
17
|
+
* My request:
|
|
18
|
+
* <the user's actual message>
|
|
19
|
+
*
|
|
20
|
+
* That block becomes the session's first user message, so Claude Code's derived
|
|
21
|
+
* preview (and thus the sidebar name) is the context text, not the request —
|
|
22
|
+
* exactly the #62 complaint. {@link stripPreloadWrapper} recovers the request.
|
|
23
|
+
*/
|
|
24
|
+
export const PRELOAD_CONTEXT_OPEN = "<project-context>";
|
|
25
|
+
/** The literal boundary between the context block and the user's real request. */
|
|
26
|
+
export const PRELOAD_REQUEST_MARKER = "</project-context>\n\nMy request:\n";
|
|
27
|
+
/**
|
|
28
|
+
* Assemble the preload context body from the project's curated docs (issue
|
|
29
|
+
* #188). OVERVIEW.md carries current state; CHANGELOG.md carries the
|
|
30
|
+
* cross-session narrative — both reach the chat so the history is no longer
|
|
31
|
+
* write-only. Each doc self-labels via its own `#` heading, so they are joined
|
|
32
|
+
* with a blank line; empty docs are dropped.
|
|
33
|
+
*/
|
|
34
|
+
export function composePreloadContext(overview, changelog) {
|
|
35
|
+
return [overview, changelog]
|
|
36
|
+
.map((s) => s.trim())
|
|
37
|
+
.filter((s) => s.length > 0)
|
|
38
|
+
.join("\n\n");
|
|
39
|
+
}
|
|
40
|
+
/** Build the preload-wrapped prompt for a new project chat's first turn. */
|
|
41
|
+
export function wrapPreload(context, message) {
|
|
42
|
+
return `${PRELOAD_CONTEXT_OPEN}\n${context.trim()}\n${PRELOAD_REQUEST_MARKER}${message}`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Recover the user's real request from a (possibly) preload-wrapped message. If
|
|
46
|
+
* `text` isn't the wrapper it's returned unchanged; if it is, everything after
|
|
47
|
+
* the `My request:` marker is returned. Returns the input unchanged when the
|
|
48
|
+
* marker is absent (e.g. the wrapper was truncated before it).
|
|
49
|
+
*/
|
|
50
|
+
export function stripPreloadWrapper(text) {
|
|
51
|
+
if (!text.startsWith(PRELOAD_CONTEXT_OPEN))
|
|
52
|
+
return text;
|
|
53
|
+
const i = text.indexOf(PRELOAD_REQUEST_MARKER);
|
|
54
|
+
return i === -1 ? text : text.slice(i + PRELOAD_REQUEST_MARKER.length);
|
|
55
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { FileEntry, FileKind } from "./project-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a freeform file name to an absolute path inside the project dir,
|
|
4
|
+
* rejecting path traversal AND traversal *through* a hidden (dot-prefixed)
|
|
5
|
+
* directory. The single guard shared by every file read and by the directory
|
|
6
|
+
* listing (issue #259). The project root itself (`name === ""`) resolves to the
|
|
7
|
+
* project dir and is allowed, so a root listing passes through.
|
|
8
|
+
*
|
|
9
|
+
* **Why hidden directories are refused, not merely omitted.** {@link listFiles}
|
|
10
|
+
* drops dot-prefixed entries from what it *returns*, which is presentation, not
|
|
11
|
+
* access control: naming the path explicitly still resolved it. And the read
|
|
12
|
+
* route's `:name` param decodes `%2F`, so a nominally single-segment route
|
|
13
|
+
* accepts a whole nested path. Together those let a caller read
|
|
14
|
+
* `…/files/.chats%2F<id>.jsonl` (a full chat transcript) or
|
|
15
|
+
* `…/files/.git%2Fconfig` (which carries credentials when a remote embeds a
|
|
16
|
+
* token). The root project (#516) widened the blast radius from one project's
|
|
17
|
+
* subtree to the instance's own backing repo and every project at once.
|
|
18
|
+
*
|
|
19
|
+
* **Why the LEAF may still be a dotfile.** Refusing every dot segment was the
|
|
20
|
+
* first cut, and it broke Changes: an UNTRACKED file has no diff, so the pane
|
|
21
|
+
* renders its content through this very surface — and `.gitignore` is untracked
|
|
22
|
+
* in a freshly-created repo-backed project, because `ensureSidecarGitignore`
|
|
23
|
+
* writes it. So a legitimate, visible-in-the-UI file became unopenable. The harm
|
|
24
|
+
* is *descending into* `.git/` and `.chats/`, not reading a dotfile git is
|
|
25
|
+
* already showing you, so the guard is scoped to directory segments.
|
|
26
|
+
* {@link listFiles} additionally refuses a hidden LEAF, because listing one is
|
|
27
|
+
* how `?path=.chats` enumerated every transcript.
|
|
28
|
+
*
|
|
29
|
+
* Honest severity: **defense-in-depth, not a privilege boundary.** Paddock has
|
|
30
|
+
* no per-user role model, and any caller who can reach these routes can already
|
|
31
|
+
* start a keeper chat and run Bash — strictly more capability than reading a
|
|
32
|
+
* file. The `/mcp` read-only token surface exposes no file verb, so it is not
|
|
33
|
+
* reachable there either. This is worth closing because "hidden in the listing"
|
|
34
|
+
* should not be the only thing standing between an API and a transcript, not
|
|
35
|
+
* because it grants anything new.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveInProject(root: string, slug: string, name: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* List one level of a project directory (issue #259). `subpath` is a
|
|
40
|
+
* project-relative directory ("" = the project root); the returned entries
|
|
41
|
+
* carry a `kind` so the UI can distinguish (and descend into) subdirectories.
|
|
42
|
+
* Dotfiles are hidden as before; entries sort directories-first, then by name.
|
|
43
|
+
*
|
|
44
|
+
* Traversal is guarded by the shared `resolveInProject`, so `subpath` can't
|
|
45
|
+
* escape the project dir. Throws `ProjectError("not_found")` when the directory
|
|
46
|
+
* doesn't exist and `ProjectError("not_directory")` when `subpath` is a file —
|
|
47
|
+
* the latter lets the caller fall back to rendering that file.
|
|
48
|
+
*/
|
|
49
|
+
export declare function listFiles(root: string, slug: string, subpath?: string): Promise<FileEntry[]>;
|
|
50
|
+
/** 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>;
|
|
52
|
+
/**
|
|
53
|
+
* Read a file's raw bytes + its MIME type (issue #61), for the binary/image
|
|
54
|
+
* endpoint. Path-traversal guarded; throws ProjectError("not_found") if the
|
|
55
|
+
* file is missing so the route can 404 cleanly. NOT decoded as text, so binary
|
|
56
|
+
* (image) bytes survive intact.
|
|
57
|
+
*/
|
|
58
|
+
export declare function readFileBytes(root: string, slug: string, name: string): Promise<{
|
|
59
|
+
bytes: Buffer;
|
|
60
|
+
mime: string;
|
|
61
|
+
}>;
|
|
62
|
+
/**
|
|
63
|
+
* Read a file plus a render-kind hint derived from its extension, for the
|
|
64
|
+
* UI's markdown/Mermaid + sandboxed-iframe renderers (issue #3) and the image
|
|
65
|
+
* viewer (issue #61).
|
|
66
|
+
*
|
|
67
|
+
* For an IMAGE the raw bytes are NOT returned here (decoding binary as UTF-8
|
|
68
|
+
* would mangle it): `content` is empty and the client fetches the bytes from
|
|
69
|
+
* the raw endpoint. We still stat the file so a missing image 404s. Path-
|
|
70
|
+
* traversal guarded; throws ProjectError("not_found") when missing.
|
|
71
|
+
*/
|
|
72
|
+
export declare function readFileWithKind(root: string, slug: string, name: string): Promise<{
|
|
73
|
+
name: string;
|
|
74
|
+
kind: FileKind;
|
|
75
|
+
content: string;
|
|
76
|
+
}>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-files — the read-only freeform-file surface for a project directory.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from projects.ts (issue #403): the path-traversal guard plus the
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { ProjectError } from "./project-paths.js";
|
|
13
|
+
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
|
+
/**
|
|
32
|
+
* Resolve a freeform file name to an absolute path inside the project dir,
|
|
33
|
+
* rejecting path traversal AND traversal *through* a hidden (dot-prefixed)
|
|
34
|
+
* directory. The single guard shared by every file read and by the directory
|
|
35
|
+
* listing (issue #259). The project root itself (`name === ""`) resolves to the
|
|
36
|
+
* project dir and is allowed, so a root listing passes through.
|
|
37
|
+
*
|
|
38
|
+
* **Why hidden directories are refused, not merely omitted.** {@link listFiles}
|
|
39
|
+
* drops dot-prefixed entries from what it *returns*, which is presentation, not
|
|
40
|
+
* access control: naming the path explicitly still resolved it. And the read
|
|
41
|
+
* route's `:name` param decodes `%2F`, so a nominally single-segment route
|
|
42
|
+
* accepts a whole nested path. Together those let a caller read
|
|
43
|
+
* `…/files/.chats%2F<id>.jsonl` (a full chat transcript) or
|
|
44
|
+
* `…/files/.git%2Fconfig` (which carries credentials when a remote embeds a
|
|
45
|
+
* token). The root project (#516) widened the blast radius from one project's
|
|
46
|
+
* subtree to the instance's own backing repo and every project at once.
|
|
47
|
+
*
|
|
48
|
+
* **Why the LEAF may still be a dotfile.** Refusing every dot segment was the
|
|
49
|
+
* first cut, and it broke Changes: an UNTRACKED file has no diff, so the pane
|
|
50
|
+
* renders its content through this very surface — and `.gitignore` is untracked
|
|
51
|
+
* in a freshly-created repo-backed project, because `ensureSidecarGitignore`
|
|
52
|
+
* writes it. So a legitimate, visible-in-the-UI file became unopenable. The harm
|
|
53
|
+
* is *descending into* `.git/` and `.chats/`, not reading a dotfile git is
|
|
54
|
+
* already showing you, so the guard is scoped to directory segments.
|
|
55
|
+
* {@link listFiles} additionally refuses a hidden LEAF, because listing one is
|
|
56
|
+
* how `?path=.chats` enumerated every transcript.
|
|
57
|
+
*
|
|
58
|
+
* Honest severity: **defense-in-depth, not a privilege boundary.** Paddock has
|
|
59
|
+
* no per-user role model, and any caller who can reach these routes can already
|
|
60
|
+
* start a keeper chat and run Bash — strictly more capability than reading a
|
|
61
|
+
* file. The `/mcp` read-only token surface exposes no file verb, so it is not
|
|
62
|
+
* reachable there either. This is worth closing because "hidden in the listing"
|
|
63
|
+
* should not be the only thing standing between an API and a transcript, not
|
|
64
|
+
* because it grants anything new.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveInProject(root, slug, name) {
|
|
67
|
+
const dir = dirFor(root, slug);
|
|
68
|
+
const resolved = path.resolve(dir, name);
|
|
69
|
+
if (resolved !== dir && !resolved.startsWith(dir + path.sep)) {
|
|
70
|
+
throw new ProjectError("Invalid file path", "invalid");
|
|
71
|
+
}
|
|
72
|
+
// Check the RESOLVED path's segments relative to the project dir, not the
|
|
73
|
+
// caller's raw string: `a/./.git/config` and `a/../.git/config` both normalise
|
|
74
|
+
// to a hidden directory segment the raw string doesn't literally contain. The
|
|
75
|
+
// project dir itself may legitimately sit under a dot-prefixed ancestor (a
|
|
76
|
+
// data dir like `/srv/.paddock/projects`), which is why only the relative part
|
|
77
|
+
// is examined. `slice(0, -1)` leaves the leaf to the caller (see doc-comment).
|
|
78
|
+
const segments = relSegments(dir, resolved);
|
|
79
|
+
if (segments.slice(0, -1).some(isHidden)) {
|
|
80
|
+
throw new ProjectError("Invalid file path", "invalid");
|
|
81
|
+
}
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
/** Path segments of `resolved` relative to `dir` ([] when they're the same). */
|
|
85
|
+
function relSegments(dir, resolved) {
|
|
86
|
+
const rel = path.relative(dir, resolved);
|
|
87
|
+
return rel ? rel.split(path.sep) : [];
|
|
88
|
+
}
|
|
89
|
+
function isHidden(segment) {
|
|
90
|
+
return segment.startsWith(".");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* List one level of a project directory (issue #259). `subpath` is a
|
|
94
|
+
* project-relative directory ("" = the project root); the returned entries
|
|
95
|
+
* carry a `kind` so the UI can distinguish (and descend into) subdirectories.
|
|
96
|
+
* Dotfiles are hidden as before; entries sort directories-first, then by name.
|
|
97
|
+
*
|
|
98
|
+
* Traversal is guarded by the shared `resolveInProject`, so `subpath` can't
|
|
99
|
+
* escape the project dir. Throws `ProjectError("not_found")` when the directory
|
|
100
|
+
* doesn't exist and `ProjectError("not_directory")` when `subpath` is a file —
|
|
101
|
+
* the latter lets the caller fall back to rendering that file.
|
|
102
|
+
*/
|
|
103
|
+
export async function listFiles(root, slug, subpath = "") {
|
|
104
|
+
const target = resolveInProject(root, slug, subpath);
|
|
105
|
+
// A LISTING target is a directory, so unlike a read the leaf gets no pass:
|
|
106
|
+
// `?path=.chats` is exactly how every transcript filename was enumerable.
|
|
107
|
+
const leaf = relSegments(dirFor(root, slug), target).at(-1);
|
|
108
|
+
if (leaf && isHidden(leaf)) {
|
|
109
|
+
throw new ProjectError("Invalid file path", "invalid");
|
|
110
|
+
}
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = await fs.readdir(target, { withFileTypes: true });
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
const code = err.code;
|
|
117
|
+
if (code === "ENOENT")
|
|
118
|
+
throw new ProjectError(`Directory not found: ${subpath}`, "not_found");
|
|
119
|
+
if (code === "ENOTDIR")
|
|
120
|
+
throw new ProjectError(`Not a directory: ${subpath}`, "not_directory");
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
return entries
|
|
124
|
+
.filter((e) => !e.name.startsWith("."))
|
|
125
|
+
.map((e) => ({ name: e.name, kind: e.isDirectory() ? "dir" : "file" }))
|
|
126
|
+
.sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1);
|
|
127
|
+
}
|
|
128
|
+
/** 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");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read a file's raw bytes + its MIME type (issue #61), for the binary/image
|
|
134
|
+
* endpoint. Path-traversal guarded; throws ProjectError("not_found") if the
|
|
135
|
+
* file is missing so the route can 404 cleanly. NOT decoded as text, so binary
|
|
136
|
+
* (image) bytes survive intact.
|
|
137
|
+
*/
|
|
138
|
+
export async function readFileBytes(root, slug, name) {
|
|
139
|
+
const resolved = resolveInProject(root, slug, name);
|
|
140
|
+
try {
|
|
141
|
+
const bytes = await fs.readFile(resolved);
|
|
142
|
+
return { bytes, mime: contentTypeFor(name) };
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err.code === "ENOENT") {
|
|
146
|
+
throw new ProjectError(`File not found: ${name}`, "not_found");
|
|
147
|
+
}
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read a file plus a render-kind hint derived from its extension, for the
|
|
153
|
+
* UI's markdown/Mermaid + sandboxed-iframe renderers (issue #3) and the image
|
|
154
|
+
* viewer (issue #61).
|
|
155
|
+
*
|
|
156
|
+
* For an IMAGE the raw bytes are NOT returned here (decoding binary as UTF-8
|
|
157
|
+
* would mangle it): `content` is empty and the client fetches the bytes from
|
|
158
|
+
* the raw endpoint. We still stat the file so a missing image 404s. Path-
|
|
159
|
+
* traversal guarded; throws ProjectError("not_found") when missing.
|
|
160
|
+
*/
|
|
161
|
+
export async function readFileWithKind(root, slug, name) {
|
|
162
|
+
const kind = fileKind(name);
|
|
163
|
+
if (kind === "image") {
|
|
164
|
+
// Existence check only — the bytes go over the raw endpoint.
|
|
165
|
+
try {
|
|
166
|
+
await fs.stat(resolveInProject(root, slug, name));
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
if (err instanceof ProjectError)
|
|
170
|
+
throw err;
|
|
171
|
+
if (err.code === "ENOENT") {
|
|
172
|
+
throw new ProjectError(`File not found: ${name}`, "not_found");
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
return { name, kind, content: "" };
|
|
177
|
+
}
|
|
178
|
+
let content;
|
|
179
|
+
try {
|
|
180
|
+
content = await readProjectFile(root, slug, name);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (err instanceof ProjectError)
|
|
184
|
+
throw err; // traversal -> "invalid"
|
|
185
|
+
if (err.code === "ENOENT") {
|
|
186
|
+
throw new ProjectError(`File not found: ${name}`, "not_found");
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
return { name, kind, content };
|
|
191
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-mime — MIME maps + file-kind/content-type helpers for project files.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from projects.ts (issue #403) as a zero-coupling leaf module: the
|
|
5
|
+
* three extension→MIME maps plus the two pure classifiers (`fileKind` for the UI
|
|
6
|
+
* renderer choice, `contentTypeFor` for the raw-byte endpoint's Content-Type).
|
|
7
|
+
* Re-exported from projects.ts so existing importers keep resolving in one place.
|
|
8
|
+
*/
|
|
9
|
+
import type { FileKind } from "./project-types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Image extensions → their MIME type, for the render kind + the raw byte
|
|
12
|
+
* endpoint's Content-Type (issue #61). SVG is included but is served with a
|
|
13
|
+
* locked-down CSP by the byte route (it can carry scripts).
|
|
14
|
+
*/
|
|
15
|
+
export declare const IMAGE_MIME: Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Video extensions → their MIME type, for the raw byte endpoint's Content-Type
|
|
18
|
+
* (issue #126). Kept SEPARATE from IMAGE_MIME so the image file-kind classifier
|
|
19
|
+
* (`fileKind`) is untouched — video only affects the content-type served, which
|
|
20
|
+
* (together with HTTP range support) is what lets a `<video>` play, incl. iOS.
|
|
21
|
+
*/
|
|
22
|
+
export declare const VIDEO_MIME: Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Non-image document extensions → their MIME type. Kept SEPARATE from
|
|
25
|
+
* `IMAGE_MIME` on purpose: the file-kind classifier (`fileKind`) treats every
|
|
26
|
+
* `IMAGE_MIME` entry as `kind: "image"`, so a `.pdf` must not live there. It's
|
|
27
|
+
* used only for the byte endpoint's Content-Type (a PDF must serve as
|
|
28
|
+
* `application/pdf`, not the octet-stream the attachment store rewrites to
|
|
29
|
+
* `text/plain`).
|
|
30
|
+
*/
|
|
31
|
+
export declare const DOCUMENT_MIME: Record<string, string>;
|
|
32
|
+
/** Derive a render kind from a file name's extension. */
|
|
33
|
+
export declare function fileKind(name: string): FileKind;
|
|
34
|
+
/** The MIME type for a file name's extension, defaulting to octet-stream. */
|
|
35
|
+
export declare function contentTypeFor(name: string): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image extensions → their MIME type, for the render kind + the raw byte
|
|
3
|
+
* endpoint's Content-Type (issue #61). SVG is included but is served with a
|
|
4
|
+
* locked-down CSP by the byte route (it can carry scripts).
|
|
5
|
+
*/
|
|
6
|
+
export const IMAGE_MIME = {
|
|
7
|
+
".png": "image/png",
|
|
8
|
+
".jpg": "image/jpeg",
|
|
9
|
+
".jpeg": "image/jpeg",
|
|
10
|
+
".gif": "image/gif",
|
|
11
|
+
".webp": "image/webp",
|
|
12
|
+
".avif": "image/avif",
|
|
13
|
+
".bmp": "image/bmp",
|
|
14
|
+
".ico": "image/x-icon",
|
|
15
|
+
".svg": "image/svg+xml",
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Video extensions → their MIME type, for the raw byte endpoint's Content-Type
|
|
19
|
+
* (issue #126). Kept SEPARATE from IMAGE_MIME so the image file-kind classifier
|
|
20
|
+
* (`fileKind`) is untouched — video only affects the content-type served, which
|
|
21
|
+
* (together with HTTP range support) is what lets a `<video>` play, incl. iOS.
|
|
22
|
+
*/
|
|
23
|
+
export const VIDEO_MIME = {
|
|
24
|
+
".mp4": "video/mp4",
|
|
25
|
+
".webm": "video/webm",
|
|
26
|
+
".mov": "video/quicktime",
|
|
27
|
+
".m4v": "video/x-m4v",
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Non-image document extensions → their MIME type. Kept SEPARATE from
|
|
31
|
+
* `IMAGE_MIME` on purpose: the file-kind classifier (`fileKind`) treats every
|
|
32
|
+
* `IMAGE_MIME` entry as `kind: "image"`, so a `.pdf` must not live there. It's
|
|
33
|
+
* used only for the byte endpoint's Content-Type (a PDF must serve as
|
|
34
|
+
* `application/pdf`, not the octet-stream the attachment store rewrites to
|
|
35
|
+
* `text/plain`).
|
|
36
|
+
*/
|
|
37
|
+
export const DOCUMENT_MIME = {
|
|
38
|
+
".pdf": "application/pdf",
|
|
39
|
+
};
|
|
40
|
+
/** Derive a render kind from a file name's extension. */
|
|
41
|
+
export function fileKind(name) {
|
|
42
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
43
|
+
if (ext === ".md" || ext === ".markdown")
|
|
44
|
+
return "markdown";
|
|
45
|
+
if (ext === ".html" || ext === ".htm")
|
|
46
|
+
return "html";
|
|
47
|
+
if (ext in IMAGE_MIME)
|
|
48
|
+
return "image";
|
|
49
|
+
return "text";
|
|
50
|
+
}
|
|
51
|
+
/** The MIME type for a file name's extension, defaulting to octet-stream. */
|
|
52
|
+
export function contentTypeFor(name) {
|
|
53
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
54
|
+
return (IMAGE_MIME[ext] ?? VIDEO_MIME[ext] ?? DOCUMENT_MIME[ext] ?? "application/octet-stream");
|
|
55
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export declare const SLUG_RE: RegExp;
|
|
2
|
+
/**
|
|
3
|
+
* A **workspace key** is a workspace's path RELATIVE to `projectsRoot`.
|
|
4
|
+
*
|
|
5
|
+
* The root workspace's key is the empty string. That is not a sentinel — it is
|
|
6
|
+
* the zero value already present in the key space, and it is what makes the
|
|
7
|
+
* resolution seam vanish instead of branch:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* dirFor(key) => path.join(this.root, key) // path.join(root, "") === root
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The previous design (issue #516) modelled the root as a *project* with a
|
|
14
|
+
* reserved slug `__root`, which forced a `dirFor` branch — duplicated in
|
|
15
|
+
* `project-files.ts`, where it was missed, 404ing every root file route. With a
|
|
16
|
+
* relative-path key there is no branch to forget.
|
|
17
|
+
*
|
|
18
|
+
* A project is a workspace with a non-empty key. Nested workspaces (`repo/sub`)
|
|
19
|
+
* are simply longer relative paths; nothing here assumes a single segment.
|
|
20
|
+
*/
|
|
21
|
+
export type WorkspaceKey = string;
|
|
22
|
+
/** The root workspace's key: its path relative to `projectsRoot` is empty. */
|
|
23
|
+
export declare const ROOT_KEY: WorkspaceKey;
|
|
24
|
+
/** Whether a key addresses the root workspace (i.e. `projectsRoot` itself). */
|
|
25
|
+
export declare function isRootKey(key: WorkspaceKey): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The root workspace's name in the **herdctl agent namespace**.
|
|
28
|
+
*
|
|
29
|
+
* This is the one place a sentinel is unavoidable, because that namespace
|
|
30
|
+
* genuinely cannot represent the empty key: herdctl agent names must be
|
|
31
|
+
* non-empty and must satisfy TWO disagreeing patterns — `AGENT_NAME_PATTERN`
|
|
32
|
+
* (`/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`, used by `addAgent`) and the session store's
|
|
33
|
+
* `SAFE_IDENTIFIER_PATTERN` (`/^[a-zA-Z0-9]([a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/`,
|
|
34
|
+
* which requires a trailing alphanumeric). A bare `keeper-` fails both, and the
|
|
35
|
+
* shape #516 originally proposed (`keeper-__root__`) registered cleanly and then
|
|
36
|
+
* threw PathTraversalError on the first resume.
|
|
37
|
+
*
|
|
38
|
+
* `_root` satisfies both patterns and can never collide with a project, because
|
|
39
|
+
* {@link SLUG_RE} rejects underscores — no slug can equal it.
|
|
40
|
+
*
|
|
41
|
+
* **This is a NAME, not an identity.** A workspace is identified by its relative
|
|
42
|
+
* path ({@link WorkspaceKey}); this encoding exists only at the herdctl
|
|
43
|
+
* boundary, is applied in one function, and keeps the four agent-name builders
|
|
44
|
+
* (`keeper-`/`sweeper-`/`hook-`/`trigger-`) uniform with no root branch.
|
|
45
|
+
*/
|
|
46
|
+
export declare const ROOT_AGENT_KEY = "_root";
|
|
47
|
+
/** Encode a workspace key for use inside a herdctl agent name. */
|
|
48
|
+
export declare function agentKeyFor(key: WorkspaceKey): string;
|
|
49
|
+
/** Decode a herdctl agent-name segment back to a workspace key. */
|
|
50
|
+
export declare function keyFromAgentKey(agentKey: string): WorkspaceKey;
|
|
51
|
+
/**
|
|
52
|
+
* Accepted repo-URL shapes for a repo-backed project (issue #187): https(s),
|
|
53
|
+
* ssh (`git@host:owner/repo`), git://, and a local `file://` or absolute path
|
|
54
|
+
* (the last two make deterministic tests possible without a network). Anything
|
|
55
|
+
* else is rejected up front so a bad value never reaches `git clone`.
|
|
56
|
+
*/
|
|
57
|
+
export declare const REPO_URL_RE: RegExp;
|
|
58
|
+
/** Validate a candidate repo URL (issue #187). */
|
|
59
|
+
export declare function isValidRepoUrl(url: string): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Derive the checkout directory NAME for a repo-backed project from its repo URL
|
|
62
|
+
* (issue #187) — the repo's own basename, sans a trailing `.git`, sanitised to a
|
|
63
|
+
* filesystem-safe token. The keeper's cwd becomes `<projectDir>/<thisName>`, so
|
|
64
|
+
* the cwd basename reads like the repo (e.g. `paddock`). Deterministic (same URL
|
|
65
|
+
* ⇒ same name), which is why `repo` is immutable once set.
|
|
66
|
+
*/
|
|
67
|
+
export declare function repoCheckoutName(repo: string): string;
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a project's working directory (keeper cwd) from its metadata dir + repo
|
|
70
|
+
* URL: the nested checkout for a repo-backed project, else the metadata dir
|
|
71
|
+
* itself for a notebook project (issue #187).
|
|
72
|
+
*/
|
|
73
|
+
export declare function workingDirFor(dir: string, repo?: string): string;
|
|
74
|
+
export declare class ProjectError extends Error {
|
|
75
|
+
readonly code: "not_found" | "exists" | "invalid" | "not_directory";
|
|
76
|
+
constructor(message: string, code: "not_found" | "exists" | "invalid" | "not_directory");
|
|
77
|
+
}
|
|
78
|
+
export declare function slugify(name: string): string;
|
|
79
|
+
export declare function today(): string;
|
|
80
|
+
/**
|
|
81
|
+
* Minimal seed for a per-project CLAUDE.md (issue #177): the durable-identity
|
|
82
|
+
* layer of the two-level native-context model (instance-wide + per-project
|
|
83
|
+
* CLAUDE.md, loaded natively once #176 lands). Deliberately terse — the sweeper
|
|
84
|
+
* amends it conservatively over time under "Curated notes"; everything a human
|
|
85
|
+
* writes is preserved. OVERVIEW.md holds current state, CHANGELOG.md holds
|
|
86
|
+
* history; this file holds only what the project durably IS and how we work on it.
|
|
87
|
+
*/
|
|
88
|
+
export declare function claudeTemplate(name: string, summary: string): string;
|