@edspencer/paddock 0.69.0 → 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/agent-errors.js +7 -1
  3. package/packages/server/dist/background-live.d.ts +16 -8
  4. package/packages/server/dist/background-live.js +41 -1
  5. package/packages/server/dist/cli/paddock.js +12 -7
  6. package/packages/server/dist/config.d.ts +7 -5
  7. package/packages/server/dist/config.js +0 -1
  8. package/packages/server/dist/git.d.ts +63 -8
  9. package/packages/server/dist/git.js +100 -20
  10. package/packages/server/dist/herdctl-jobs.d.ts +30 -4
  11. package/packages/server/dist/herdctl-jobs.js +35 -8
  12. package/packages/server/dist/instance-config.js +1 -2
  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/recovery-config.d.ts +0 -6
  18. package/packages/server/dist/recovery-config.js +0 -4
  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 +11 -1
  22. package/packages/server/package.json +1 -1
  23. package/packages/web/dist/assets/{Chip-B8UU8Zpr.js → Chip-P4n88XYj.js} +1 -1
  24. package/packages/web/dist/assets/DiscoverPage-B7ap1I1Y.js +2 -0
  25. package/packages/web/dist/assets/{DiscoverView-CKN93BnT.js → DiscoverView-DxC3mmGp.js} +1 -1
  26. package/packages/web/dist/assets/{InstanceConfigPage-COc2rsu6.js → InstanceConfigPage-DO87_86Z.js} +1 -1
  27. package/packages/web/dist/assets/{MarkdownRenderer-BlUYSdHR.js → MarkdownRenderer-DatKu1Pl.js} +1 -1
  28. package/packages/web/dist/assets/{ProjectMenu-Davm1-P7.js → ProjectMenu-DJB7uCGt.js} +1 -1
  29. package/packages/web/dist/assets/{ProjectRedirect-COrC7Yvg.js → ProjectRedirect-D5Uxki7M.js} +1 -1
  30. package/packages/web/dist/assets/ProjectView-D8TqrBW4.js +13 -0
  31. package/packages/web/dist/assets/{ProjectsGrid-CfDehr5s.js → ProjectsGrid-CqQTeqdE.js} +1 -1
  32. package/packages/web/dist/assets/{RootHome-DniHfYAw.js → RootHome-B0si7ijq.js} +1 -1
  33. package/packages/web/dist/assets/{Surfaces-GzOkJJHQ.js → Surfaces-t3XF_my5.js} +1 -1
  34. package/packages/web/dist/assets/{arc-_I2xwzbW.js → arc-Bvnrokeb.js} +1 -1
  35. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-GW4T_vCB.js → architectureDiagram-3BPJPVTR-Dc5OXCmK.js} +1 -1
  36. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-rpMzVG3O.js → blockDiagram-GPEHLZMM-CFZeUqSH.js} +1 -1
  37. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CwxpNlW3.js → c4Diagram-AAUBKEIU-BPI5UmpX.js} +1 -1
  38. package/packages/web/dist/assets/channel-BGIf4GoQ.js +2 -0
  39. package/packages/web/dist/assets/{chunk-2J33WTMH-BG9SyFJ-.js → chunk-2J33WTMH-CVpthvPT.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-4BX2VUAB-DJlbnTd4.js → chunk-4BX2VUAB-cTaBnmzz.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-55IACEB6-C-VMlU4m.js → chunk-55IACEB6-dkgF9sAS.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-727SXJPM-Wigu1170.js → chunk-727SXJPM-UiFZch2W.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-AQP2D5EJ-C5CFWtjs.js → chunk-AQP2D5EJ-IKsoQNZy.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-FMBD7UC4-hQaZU9Hi.js → chunk-FMBD7UC4-Tzt_HckZ.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-ND2GUHAM-AMO00QGi.js → chunk-ND2GUHAM-dbNwUFdT.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-QZHKN3VN-T3F7CT-B.js → chunk-QZHKN3VN-eUsG1OkP.js} +1 -1
  47. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-6kluxBAR.js +2 -0
  48. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-6kluxBAR.js +2 -0
  49. package/packages/web/dist/assets/{core-D1JA8p71.js → core-Duun4AtS.js} +1 -1
  50. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-C_fPZY7X.js → cose-bilkent-S5V4N54A-KF_gNdsd.js} +1 -1
  51. package/packages/web/dist/assets/{dagre-BM42HDAG-nLjMp3c7.js → dagre-BM42HDAG-CXFDGeP7.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-2AECGRRQ-qYzVcYYg.js → diagram-2AECGRRQ-BiefFjo3.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-5GNKFQAL-DVFKMWv0.js → diagram-5GNKFQAL-B1IpANC8.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-KO2AKTUF-B8T7qnCR.js → diagram-KO2AKTUF-ChdOA3EK.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-LMA3HP47-9-_-MiG8.js → diagram-LMA3HP47-BTbLDsXf.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-OG6HWLK6-D4eE55Vg.js → diagram-OG6HWLK6-Bqiqt27L.js} +1 -1
  57. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-adlALhlq.js → erDiagram-TEJ5UH35-KA9WLkV0.js} +1 -1
  58. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-jhJEXK0D.js → flowDiagram-I6XJVG4X-CZKvoeTz.js} +1 -1
  59. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CHGNR8Fk.js → ganttDiagram-6RSMTGT7-o1QDzlgt.js} +1 -1
  60. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-tBQ_60zD.js → gitGraphDiagram-PVQCEYII-D5GdOppF.js} +1 -1
  61. package/packages/web/dist/assets/index-4ugpmAz4.css +1 -0
  62. package/packages/web/dist/assets/{index-Djl4krRr.js → index-B-J2K1w2.js} +13 -13
  63. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-D_LJZit4.js → infoDiagram-5YYISTIA-21Z9hMO-.js} +1 -1
  64. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CLsVMtFo.js → ishikawaDiagram-YF4QCWOH-TmbPK40i.js} +1 -1
  65. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DKLfsczG.js → journeyDiagram-JHISSGLW-DYAWKu-K.js} +1 -1
  66. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CG1dRRin.js → kanban-definition-UN3LZRKU-ZW_ZQ0DQ.js} +1 -1
  67. package/packages/web/dist/assets/{linear-Bc0TsEWL.js → linear-BDQKnwHn.js} +1 -1
  68. package/packages/web/dist/assets/{mermaid.core-7NBz7_-Q.js → mermaid.core-BHXnmT7N.js} +4 -4
  69. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DD-2NsoA.js → mindmap-definition-RKZ34NQL-CHVPYLQs.js} +1 -1
  70. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BTYpRP3j.js → pieDiagram-4H26LBE5-paqmu057.js} +1 -1
  71. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DScmlT0o.js → quadrantDiagram-W4KKPZXB-diM_4CGS.js} +1 -1
  72. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BzLuYCY3.js → requirementDiagram-4Y6WPE33-DCyNbyIz.js} +1 -1
  73. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-CH9nUHfL.js → sankeyDiagram-5OEKKPKP-DxpEvGOy.js} +1 -1
  74. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-vQjaE3Ay.js → sequenceDiagram-3UESZ5HK-BH4-7BdR.js} +1 -1
  75. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-C76FMvr4.js → stateDiagram-AJRCARHV-CVPrwckU.js} +1 -1
  76. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CMmy9TKm.js +2 -0
  77. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-zjhYOHh7.js → timeline-definition-PNZ67QCA-B6_yfyHl.js} +1 -1
  78. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BpyreyjG.js → vennDiagram-CIIHVFJN-Ic8OogjJ.js} +1 -1
  79. package/packages/web/dist/assets/{wardley-L42UT6IY-CugQjZZ8.js → wardley-L42UT6IY-CuhgMTiv.js} +1 -1
  80. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CTpxg9iQ.js → wardleyDiagram-YWT4CUSO-Djeda9cE.js} +1 -1
  81. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-COJLOF01.js → xychartDiagram-2RQKCTM6-D3hl8Mdh.js} +1 -1
  82. package/packages/web/dist/index.html +2 -2
  83. package/packages/web/dist/sw.js +1 -1
  84. package/packages/web/dist/assets/DiscoverPage-vz1s8GYQ.js +0 -2
  85. package/packages/web/dist/assets/ProjectView-WIODXM93.js +0 -13
  86. package/packages/web/dist/assets/channel-nPAjQX8p.js +0 -2
  87. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuI-4XNG.js +0 -2
  88. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuI-4XNG.js +0 -2
  89. package/packages/web/dist/assets/index-CJT1g4_p.css +0 -1
  90. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CWy9AQvb.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.69.0",
3
+ "version": "0.70.1",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -44,8 +44,14 @@ const KNOWN_CAUSES = [
44
44
  // Anchored to `claude` specifically. A bare /ENOENT/ would claim a missing
45
45
  // CLI for any file the engine failed to open, which is a confident wrong
46
46
  // answer — worse than the raw error it replaces.
47
+ //
48
+ // Name only what actually reads PATH (#771): the CLI runtime. The sweeper
49
+ // is always on it; triggers and chat turns only on `driveMode: batch`. An
50
+ // earlier wording said "the sweeper and triggers", which sent an operator
51
+ // on the default `session` mode to check the wrong thing.
47
52
  pattern: /ENOENT[^\n]{0,40}claude|claude: (command )?not found/i,
48
- cause: "the `claude` CLI is not on PATH (needed by the sweeper and triggers)",
53
+ cause: "the `claude` CLI is not on PATH (needed by the sweeper, and by " +
54
+ "triggers/turns on `driveMode: batch`)",
49
55
  },
50
56
  ];
51
57
  /**
@@ -35,29 +35,37 @@
35
35
  * a restart is correct, because Paddock stops the fleet with
36
36
  * `waitForJobs: false` and the tasks are genuinely dead.
37
37
  */
38
+ /**
39
+ * The role for an SDK task type. Identity for anything unrecognised, which also
40
+ * means a future SDK that starts sending friendly names needs no change here.
41
+ */
42
+ export declare function roleForTaskType(type: string): string;
38
43
  /**
39
44
  * One live background task, as broadcast to clients.
40
45
  *
41
- * `type` is the SDK's friendly label (`shell` | `subagent` | `monitor` |
42
- * `workflow`), falling back to the raw discriminant for kinds we do not know
43
- * about yet — deliberately a plain `string` rather than a union so a new SDK
44
- * task type renders as an unlabelled row instead of being dropped.
46
+ * `type` is the RAW SDK discriminant (`local_bash`, `local_agent`, …), kept
47
+ * verbatim so an unexpected kind is diagnosable from the wire. `role` is the
48
+ * rendering role derived from it by {@link roleForTaskType}. Both are plain
49
+ * `string`s rather than unions on purpose: a new SDK task type should render as
50
+ * a generic row, not be dropped.
45
51
  */
46
52
  export interface LiveBackgroundTask {
47
53
  id: string;
48
54
  type: string;
55
+ /** Rendering role derived from {@link type} — see {@link roleForTaskType}. */
56
+ role: string;
49
57
  description: string;
50
58
  /** Epoch-ms this task was first observed. Stamped here; the SDK sends no start time. */
51
59
  startedAt: number;
52
60
  /** Links the task to its launching tool card, when an edge has told us. */
53
61
  toolUseId?: string;
54
- /** `subagent` only. */
62
+ /** `subagent` role only. */
55
63
  agentType?: string;
56
- /** `shell` only. */
64
+ /** `shell` role only. */
57
65
  command?: string;
58
- /** `workflow` only. */
66
+ /** `workflow` role only. */
59
67
  workflowName?: string;
60
- /** `monitor` / MCP-task only. */
68
+ /** `monitor` / MCP-task role only. */
61
69
  server?: string;
62
70
  tool?: string;
63
71
  /** Latest tool the task ran, from `task_progress`. */
@@ -37,6 +37,44 @@
37
37
  */
38
38
  /** Terminal task statuses — a task in one of these is no longer live. */
39
39
  const TERMINAL_STATUSES = new Set(["completed", "failed", "stopped", "killed"]);
40
+ /**
41
+ * SDK task-type discriminant → the role clients render (issue #846).
42
+ *
43
+ * This module used to document `type` as "the SDK's friendly label (`shell` |
44
+ * `subagent` | `monitor` | `workflow`)" and pass `task_type` straight through.
45
+ * **The CLI does no such mapping.** Its level payload is built by
46
+ *
47
+ * ```js
48
+ * .map((t) => ({ task_id: t.id, task_type: t.type, description: t.description }))
49
+ * ```
50
+ *
51
+ * — the INTERNAL discriminant verbatim. None of the four friendly names is in
52
+ * the CLI's own id-prefix table, so before this map every row fell through the
53
+ * client's unknown-kind fallback to a raw `local_bash` and a neutral clock.
54
+ * There was no case in which the friendly-name branches fired.
55
+ *
56
+ * The translation lives HERE, at the registry boundary, so one mapping serves
57
+ * every consumer rather than each of them re-deriving it. An unrecognised kind
58
+ * still falls through to its raw string — that fallback was always right, it
59
+ * just should not have been the only path.
60
+ */
61
+ const TASK_ROLES = {
62
+ local_bash: "shell",
63
+ local_agent: "subagent",
64
+ remote_agent: "subagent",
65
+ in_process_teammate: "subagent",
66
+ local_workflow: "workflow",
67
+ monitor_mcp: "monitor",
68
+ monitor_ws: "monitor",
69
+ mcp_task: "task",
70
+ };
71
+ /**
72
+ * The role for an SDK task type. Identity for anything unrecognised, which also
73
+ * means a future SDK that starts sending friendly names needs no change here.
74
+ */
75
+ export function roleForTaskType(type) {
76
+ return TASK_ROLES[type] ?? type;
77
+ }
40
78
  const str = (v) => (typeof v === "string" && v ? v : undefined);
41
79
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
42
80
  /**
@@ -159,10 +197,12 @@ export class BackgroundRegistry {
159
197
  if (!id)
160
198
  continue;
161
199
  const existing = prev?.tasks.get(id);
200
+ const type = str(rt.task_type) ?? existing?.type ?? "task";
162
201
  next.set(id, {
163
202
  ...existing,
164
203
  id,
165
- type: str(rt.task_type) ?? existing?.type ?? "task",
204
+ type,
205
+ role: roleForTaskType(type),
166
206
  description: str(rt.description) ?? existing?.description ?? "",
167
207
  startedAt: existing?.startedAt ?? this.now(),
168
208
  });
@@ -78,14 +78,19 @@ function readVersion() {
78
78
  /**
79
79
  * Put our bundled `claude` on PATH for child processes.
80
80
  *
81
- * Chat turns do NOT need this: they run herdctl's SDK runtime, which resolves
82
- * the binary from the Claude Agent SDK's own platform package via
83
- * `require.resolve` and never consults PATH. But the post-turn sweeper and all
84
- * triggers are one-shot `trigger()` calls on the CLI runtime, which does
85
- * `execa("claude", …)` — by name, without execa's `preferLocal`, so a local
86
- * install is invisible to it unless PATH says otherwise.
81
+ * Chat turns do NOT need this on the default `driveMode: session`: they run
82
+ * herdctl's SDK runtime, which resolves the binary from the Claude Agent SDK's
83
+ * own platform package via `require.resolve` and never consults PATH. The
84
+ * post-turn sweeper is the one unconditional user: always a one-shot
85
+ * `trigger()` call on the CLI runtime, which does `execa("claude", …)` — by
86
+ * name, without execa's `preferLocal`, so a local install is invisible to it
87
+ * unless PATH says otherwise. Triggers join it there only on `driveMode:
88
+ * batch`: a trigger resolves its drive mode exactly like a chat does
89
+ * (per-project override else `cfg.driveMode` — `resolveDriveMode` in
90
+ * ws-triggers.ts), so on a stock instance no trigger consults PATH either.
87
91
  *
88
- * Without this, chats work and every sweep/trigger fails (logged, non-fatal).
92
+ * Without this, chats work and every sweep fails (logged, non-fatal) — plus
93
+ * every trigger and turn on `driveMode: batch`.
89
94
  */
90
95
  function addBundledBinsToPath() {
91
96
  const owner = findUp(path.join("node_modules", ".bin"));
@@ -386,11 +386,14 @@ export interface PaddockConfig {
386
386
  warnings: string[];
387
387
  };
388
388
  /**
389
- * Instance default for the hook-management MCP (Epic G / G5, GG-4) — the
390
- * `mcp__paddock_manage__{list,set,remove}_hook` tools that let a project agent
391
- * declare/edit/delete its own event hooks. A sibling of {@link selfMcpWriteEnabled}:
389
+ * Instance default for the hook-management MCP (Epic G / G5, GG-4) — now the
390
+ * unified TRIGGER tools `mcp__paddock_manage__{list_triggers,set_trigger,
391
+ * remove_trigger,run_trigger}` that let a project agent declare/edit/delete/fire
392
+ * its own triggers. Epic T / T3 collapsed the former `*_hook` and `*_schedule`
393
+ * verbs into these; the flag and its env var kept the `hooks` spelling.
394
+ * A sibling of {@link selfMcpWriteEnabled}:
392
395
  * OFF by default (opt-in), and only surfaces when the self-MCP write tools are also
393
- * present (the hook tools live on the same injected server, appended in the write
396
+ * present (the trigger tools live on the same injected server, appended in the write
394
397
  * block). Driven by `PADDOCK_HOOKS_MCP`; accepts 1/true/yes. A per-project
395
398
  * `hooksMcpEnabled` override wins at dispatch (resolved via
396
399
  * {@link import("./hook-config.js").resolveHooksMcpEnabled}), the same
@@ -580,7 +583,6 @@ export interface PaddockConfigFile {
580
583
  autoReDrive?: boolean | string;
581
584
  debounceMs?: number | string;
582
585
  maxRetries?: number | string;
583
- limboTimeoutMs?: number | string;
584
586
  };
585
587
  /**
586
588
  * Inbound composer-attachment config (issue #328). Every field optional; a
@@ -456,7 +456,6 @@ function loadRecoveryConfig(file) {
456
456
  autoReDrive: loadRecoveryBool("PADDOCK_RECOVERY_AUTODRIVE", f.autoReDrive, DEFAULT_RECOVERY.autoReDrive),
457
457
  debounceMs: loadRecoveryInt("PADDOCK_RECOVERY_DEBOUNCE_MS", f.debounceMs, DEFAULT_RECOVERY.debounceMs),
458
458
  maxRetries: loadRecoveryInt("PADDOCK_RECOVERY_MAX_RETRIES", f.maxRetries, DEFAULT_RECOVERY.maxRetries),
459
- limboTimeoutMs: loadRecoveryInt("PADDOCK_RECOVERY_LIMBO_MS", f.limboTimeoutMs, DEFAULT_RECOVERY.limboTimeoutMs),
460
459
  };
461
460
  }
462
461
  /**
@@ -65,10 +65,11 @@ export declare class GitService {
65
65
  * successful check (repo-ness doesn't change under a running server); a thrown
66
66
  * git / missing-binary error resolves to `false`.
67
67
  *
68
- * Store-wide, so it still gates the store-wide operations ({@link dirtyCounts},
69
- * {@link remote}, {@link push}). The PER-PROJECT surface asks
70
- * {@link isRepoAt} about the project's own working directory instead — see
71
- * that method for why the two had to be separated.
68
+ * Store-wide, so it still gates the one genuinely store-wide operation,
69
+ * {@link dirtyCounts} — a single `git status` over the whole root. Everything
70
+ * else asks {@link isRepoAt} about the directory it is about to act on: the
71
+ * project's own working directory for status/diff/commit (#597, #206) and for
72
+ * remote/push (#710). See {@link isRepoAt} for why the two had to be separated.
72
73
  */
73
74
  isRepo(): Promise<boolean>;
74
75
  /**
@@ -101,6 +102,40 @@ export declare class GitService {
101
102
  * `projectDir`.
102
103
  */
103
104
  projectStatus(projectDir: string): Promise<GitProjectStatus>;
105
+ /**
106
+ * Whether `relPath` is a file git currently reports as UNTRACKED in
107
+ * `projectDir` — the read gate for the Changes tab's new-file view (#710).
108
+ *
109
+ * ## Why git's own answer, and not a path guard
110
+ *
111
+ * The Changes tab renders an untracked file's CONTENT (it has no diff), and
112
+ * until #710 it did so through the metadata-dir file surface — which 404'd for
113
+ * every project whose working directory is somewhere else. Serving the file
114
+ * from the working directory instead needs a guard, and the obvious one to
115
+ * reach for is `project-files.ts`'s: refuse any path descending through a
116
+ * dot-prefixed directory. That guard is right for the metadata dir and wrong
117
+ * here, in both directions:
118
+ *
119
+ * - **It under-blocks on a worktree.** A linked worktree's `.git` is a FILE,
120
+ * not a directory, so it is the LEAF — and the leaf is deliberately allowed
121
+ * there (an untracked `.gitignore` has to render). The file is only a
122
+ * `gitdir:` pointer, but serving it discloses the main checkout's path for
123
+ * no reason.
124
+ * - **It over-blocks on a real repo.** A brand-new untracked
125
+ * `.github/workflows/ci.yml` is an ordinary Changes-tab row, and a
126
+ * dot-directory rule refuses to render exactly the file the UI is listing.
127
+ *
128
+ * Asking git is both tighter and looser in the right places. `git status`
129
+ * never reports `.git` (file or directory), never reports an IGNORED path —
130
+ * so `.chats/` and anything else the repo excludes stays unreadable — and does
131
+ * report `.github/…`. The set the route may serve is then, by construction,
132
+ * exactly the set the pane already displays: no third notion of "allowed" to
133
+ * keep in step with the other two.
134
+ *
135
+ * `:(literal)` disables pathspec magic, so a filename containing `*` or `[` is
136
+ * matched as itself rather than as a pattern. False on any git failure.
137
+ */
138
+ isUntrackedFile(projectDir: string, relPath: string): Promise<boolean>;
104
139
  /**
105
140
  * `git diff --numstat` for a project's tracked working-tree changes, keyed by
106
141
  * PROJECT-relative path (`--relative` makes git emit paths relative to the
@@ -116,8 +151,23 @@ export declare class GitService {
116
151
  projectDiff(projectDir: string, file?: string): Promise<string>;
117
152
  /** Commit identity (from config, no global git config needed). */
118
153
  private identity;
119
- /** Origin remote info + ahead/behind vs upstream (best-effort). */
120
- remote(): Promise<{
154
+ /**
155
+ * Origin remote info + ahead/behind vs upstream (best-effort) for ONE
156
+ * directory's repository, defaulting to the backing store.
157
+ *
158
+ * The `dir` parameter is issue #710. `remote()` and {@link push} were the last
159
+ * two members of the per-project Changes surface still hard-wired to
160
+ * `projectsRoot` after #597/#709 moved status/diff/commit onto the project's
161
+ * `workingDir` — which made the pane's header self-contradictory: it showed the
162
+ * WORKTREE's branch beside an ahead-count, a remote URL and a Push button that
163
+ * all belonged to Paddock's own notes repo. Passing the working directory makes
164
+ * every field in that header describe one repository.
165
+ *
166
+ * For a notebook project (and for the root workspace) the working directory is
167
+ * inside `projectsRoot`, so this resolves to the same repo, same branch, same
168
+ * upstream as before — the generalisation costs those shapes nothing.
169
+ */
170
+ remote(dir?: string): Promise<{
121
171
  repo: boolean;
122
172
  configured: boolean;
123
173
  url?: string;
@@ -163,8 +213,13 @@ export declare class GitService {
163
213
  * Returns 0 for a directory that isn't a repo, matching "nothing to report".
164
214
  */
165
215
  dirtyCountAt(dir: string): Promise<number>;
166
- /** Push the current branch to origin (sets upstream on first push). */
167
- push(): Promise<{
216
+ /**
217
+ * Push one directory's current branch to origin (sets upstream on first push),
218
+ * defaulting to the backing store. See {@link remote} for why `dir` exists —
219
+ * in a linked WORKTREE `--abbrev-ref HEAD` is per-worktree, so this pushes the
220
+ * branch the user is actually looking at rather than the store's.
221
+ */
222
+ push(dir?: string): Promise<{
168
223
  pushed: boolean;
169
224
  error?: string;
170
225
  }>;
@@ -77,10 +77,11 @@ export class GitService {
77
77
  * successful check (repo-ness doesn't change under a running server); a thrown
78
78
  * git / missing-binary error resolves to `false`.
79
79
  *
80
- * Store-wide, so it still gates the store-wide operations ({@link dirtyCounts},
81
- * {@link remote}, {@link push}). The PER-PROJECT surface asks
82
- * {@link isRepoAt} about the project's own working directory instead — see
83
- * that method for why the two had to be separated.
80
+ * Store-wide, so it still gates the one genuinely store-wide operation,
81
+ * {@link dirtyCounts} — a single `git status` over the whole root. Everything
82
+ * else asks {@link isRepoAt} about the directory it is about to act on: the
83
+ * project's own working directory for status/diff/commit (#597, #206) and for
84
+ * remote/push (#710). See {@link isRepoAt} for why the two had to be separated.
84
85
  */
85
86
  async isRepo() {
86
87
  if (this.repoFlag !== null)
@@ -198,6 +199,70 @@ export class GitService {
198
199
  }
199
200
  return { repo: true, branch, files, clean: files.length === 0 };
200
201
  }
202
+ /**
203
+ * Whether `relPath` is a file git currently reports as UNTRACKED in
204
+ * `projectDir` — the read gate for the Changes tab's new-file view (#710).
205
+ *
206
+ * ## Why git's own answer, and not a path guard
207
+ *
208
+ * The Changes tab renders an untracked file's CONTENT (it has no diff), and
209
+ * until #710 it did so through the metadata-dir file surface — which 404'd for
210
+ * every project whose working directory is somewhere else. Serving the file
211
+ * from the working directory instead needs a guard, and the obvious one to
212
+ * reach for is `project-files.ts`'s: refuse any path descending through a
213
+ * dot-prefixed directory. That guard is right for the metadata dir and wrong
214
+ * here, in both directions:
215
+ *
216
+ * - **It under-blocks on a worktree.** A linked worktree's `.git` is a FILE,
217
+ * not a directory, so it is the LEAF — and the leaf is deliberately allowed
218
+ * there (an untracked `.gitignore` has to render). The file is only a
219
+ * `gitdir:` pointer, but serving it discloses the main checkout's path for
220
+ * no reason.
221
+ * - **It over-blocks on a real repo.** A brand-new untracked
222
+ * `.github/workflows/ci.yml` is an ordinary Changes-tab row, and a
223
+ * dot-directory rule refuses to render exactly the file the UI is listing.
224
+ *
225
+ * Asking git is both tighter and looser in the right places. `git status`
226
+ * never reports `.git` (file or directory), never reports an IGNORED path —
227
+ * so `.chats/` and anything else the repo excludes stays unreadable — and does
228
+ * report `.github/…`. The set the route may serve is then, by construction,
229
+ * exactly the set the pane already displays: no third notion of "allowed" to
230
+ * keep in step with the other two.
231
+ *
232
+ * `:(literal)` disables pathspec magic, so a filename containing `*` or `[` is
233
+ * matched as itself rather than as a pattern. False on any git failure.
234
+ */
235
+ async isUntrackedFile(projectDir, relPath) {
236
+ if (!isSafeRelPath(relPath))
237
+ return false;
238
+ if (!(await this.isRepoAt(projectDir)))
239
+ return false;
240
+ // Porcelain paths are REPO-ROOT relative; the caller's path is PROJECT
241
+ // relative. Rebase with the same `--show-prefix` the status route uses, so
242
+ // a project dir below the repo root compares like for like.
243
+ let prefix = "";
244
+ try {
245
+ prefix = (await this.git(projectDir, ["rev-parse", "--show-prefix"])).trim();
246
+ }
247
+ catch {
248
+ /* project dir is the repo root */
249
+ }
250
+ try {
251
+ const out = await this.git(projectDir, [
252
+ "status",
253
+ "--porcelain=v1",
254
+ "-z",
255
+ "--untracked-files=all",
256
+ "--",
257
+ `:(literal)${relPath}`,
258
+ ]);
259
+ return parsePorcelainZ(out).some((f) => f.untracked &&
260
+ (prefix && f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path) === relPath);
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
201
266
  /**
202
267
  * `git diff --numstat` for a project's tracked working-tree changes, keyed by
203
268
  * PROJECT-relative path (`--relative` makes git emit paths relative to the
@@ -248,13 +313,28 @@ export class GitService {
248
313
  identity() {
249
314
  return this.author;
250
315
  }
251
- /** Origin remote info + ahead/behind vs upstream (best-effort). */
252
- async remote() {
253
- if (!(await this.isRepo()))
316
+ /**
317
+ * Origin remote info + ahead/behind vs upstream (best-effort) for ONE
318
+ * directory's repository, defaulting to the backing store.
319
+ *
320
+ * The `dir` parameter is issue #710. `remote()` and {@link push} were the last
321
+ * two members of the per-project Changes surface still hard-wired to
322
+ * `projectsRoot` after #597/#709 moved status/diff/commit onto the project's
323
+ * `workingDir` — which made the pane's header self-contradictory: it showed the
324
+ * WORKTREE's branch beside an ahead-count, a remote URL and a Push button that
325
+ * all belonged to Paddock's own notes repo. Passing the working directory makes
326
+ * every field in that header describe one repository.
327
+ *
328
+ * For a notebook project (and for the root workspace) the working directory is
329
+ * inside `projectsRoot`, so this resolves to the same repo, same branch, same
330
+ * upstream as before — the generalisation costs those shapes nothing.
331
+ */
332
+ async remote(dir = this.projectsRoot) {
333
+ if (!(await this.isRepoAt(dir)))
254
334
  return { repo: false, configured: false };
255
335
  let branch;
256
336
  try {
257
- branch = (await this.git(this.projectsRoot, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
337
+ branch = (await this.git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
258
338
  if (branch === "HEAD")
259
339
  branch = undefined;
260
340
  }
@@ -263,7 +343,7 @@ export class GitService {
263
343
  }
264
344
  let url;
265
345
  try {
266
- url = (await this.git(this.projectsRoot, ["remote", "get-url", "origin"])).trim();
346
+ url = (await this.git(dir, ["remote", "get-url", "origin"])).trim();
267
347
  }
268
348
  catch {
269
349
  return { repo: true, configured: false, branch };
@@ -271,12 +351,7 @@ export class GitService {
271
351
  let ahead;
272
352
  let behind;
273
353
  try {
274
- const counts = (await this.git(this.projectsRoot, [
275
- "rev-list",
276
- "--left-right",
277
- "--count",
278
- "@{upstream}...HEAD",
279
- ])).trim();
354
+ const counts = (await this.git(dir, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])).trim();
280
355
  const [b, a] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10));
281
356
  behind = Number.isFinite(b) ? b : undefined;
282
357
  ahead = Number.isFinite(a) ? a : undefined;
@@ -393,13 +468,18 @@ export class GitService {
393
468
  return 0;
394
469
  }
395
470
  }
396
- /** Push the current branch to origin (sets upstream on first push). */
397
- async push() {
398
- if (!(await this.isRepo()))
471
+ /**
472
+ * Push one directory's current branch to origin (sets upstream on first push),
473
+ * defaulting to the backing store. See {@link remote} for why `dir` exists —
474
+ * in a linked WORKTREE `--abbrev-ref HEAD` is per-worktree, so this pushes the
475
+ * branch the user is actually looking at rather than the store's.
476
+ */
477
+ async push(dir = this.projectsRoot) {
478
+ if (!(await this.isRepoAt(dir)))
399
479
  return { pushed: false, error: "not a repo" };
400
480
  try {
401
- const branch = (await this.git(this.projectsRoot, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
402
- await this.git(this.projectsRoot, ["push", "--set-upstream", "origin", branch]);
481
+ const branch = (await this.git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
482
+ await this.git(dir, ["push", "--set-upstream", "origin", branch]);
403
483
  return { pushed: true };
404
484
  }
405
485
  catch (err) {
@@ -66,10 +66,36 @@ export declare function listProjectRuns(stateDir: string, project: Project, limi
66
66
  * the data source for the Triggers tab's per-trigger last-run column. Used to pull
67
67
  * one project's keeper AND every scoped `trigger-<slug>-<name>` agent in a single
68
68
  * pass so {@link import("./trigger-runtime.js").buildTriggerRuntime} can attribute a
69
- * scoped trigger's newest run by agent name. `listJobs` has no multi-agent filter,
70
- * so this scans the jobs dir once (unfiltered) and keeps only the requested agents;
71
- * order (started_at descending) is preserved. Errors swallow to `[]` so the runtime
72
- * view degrades to config-only rather than failing to render.
69
+ * scoped trigger's newest run by agent name. Order (started_at descending) is
70
+ * preserved. Errors swallow to `[]` so the runtime view degrades to config-only
71
+ * rather than failing to render.
72
+ *
73
+ * ## Why the filter is pushed down (#535)
74
+ *
75
+ * This used to call `listJobs(jobsDir)` with NEITHER filter nor limit, then
76
+ * filter and slice in JS — because `ListJobsFilter` had `agent` (exactly one)
77
+ * and no multi-agent form. That defeats core's job index completely: with
78
+ * `filter.limit === undefined` core sets `retain = matches`, so EVERY record in
79
+ * the shared, never-pruned, fleet-wide jobs dir is read, YAML-parsed and
80
+ * Zod-validated, then thrown away. Measured on a 2,016-record dir:
81
+ *
82
+ * | call | warm |
83
+ * |---|---|
84
+ * | `listJobs(dir)` + JS filter + slice | 1,935–2,095 ms |
85
+ * | `listJobs(dir, { agents, limit })` | 125–146 ms |
86
+ *
87
+ * That ~2 s was essentially the whole cost of `GET /triggers/runtime`, which the
88
+ * Triggers tab polls every 10 s.
89
+ *
90
+ * Both `agents` AND `limit` are load-bearing. `agents` alone still leaves
91
+ * `limit` undefined, so `retain = matches` and every match is hydrated anyway —
92
+ * the win comes from the pair. `limit <= 0` keeps its documented "no cap"
93
+ * meaning and is passed as no `limit`, which costs more but is still filtered.
94
+ *
95
+ * Requires `@herdctl/core` >= 5.28.0 (herdctl#418). On an older core `agents` is
96
+ * an unknown key and would be silently ignored, returning the newest `limit`
97
+ * records across ALL agents — hence the version floor in package.json rather
98
+ * than a defensive re-filter here, which would mask the mismatch.
73
99
  */
74
100
  export declare function listRunsForAgents(stateDir: string, agents: string[], limit?: number): Promise<JobMetadata[]>;
75
101
  /**
@@ -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`
@@ -254,7 +254,7 @@ export const FIELDS = [
254
254
  { key: "environmentPrompt", group: "capabilities", label: "Environment prompt", help: "Appended to every keeper turn's system prompt: what the agent should know about rendering into Paddock rather than a terminal. Clear it to append nothing.", type: "text", envVars: ["PADDOCK_ENVIRONMENT_PROMPT"], envShadowWhenDefined: true, default: DEFAULT_ENVIRONMENT_PROMPT, editable: true, coerce: promptText },
255
255
  { key: "selfMcpEnabled", group: "capabilities", label: "Self-management MCP (read)", help: "Let keepers list/read projects and other chats.", type: "boolean", envVars: ["PADDOCK_SELF_MCP"], default: false, editable: true, coerce: asBool },
256
256
  { key: "selfMcpWriteEnabled", group: "capabilities", label: "Self-management MCP (write)", help: "Let keepers create/fork/message chats (needs read enabled too).", type: "boolean", envVars: ["PADDOCK_SELF_MCP_WRITE"], default: false, editable: true, coerce: asBool },
257
- { key: "selfMcpProjectsEnabled", group: "capabilities", label: "Self-management MCP (projects)", help: "Let keepers create whole new projects, cloning a repo when repo-backed (needs self-MCP write).", type: "boolean", envVars: ["PADDOCK_SELF_MCP_PROJECTS"], default: false, editable: true, coerce: asBool },
257
+ { key: "selfMcpProjectsEnabled", group: "capabilities", label: "Self-management MCP (projects)", help: "Let keepers create new projects and promote existing ones — including a git clone of a repo URL the agent supplies (needs self-MCP write).", type: "boolean", envVars: ["PADDOCK_SELF_MCP_PROJECTS"], default: false, editable: true, coerce: asBool },
258
258
  { key: "maxSpawnDepth", group: "capabilities", label: "Max spawn depth", help: "How deep a spawn tree may grow before children lose the self-MCP.", type: "number", envVars: ["PADDOCK_MAX_SPAWN_DEPTH"], default: DEFAULT_MAX_SPAWN_DEPTH, editable: true, coerce: spawnDepth },
259
259
  { key: "scheduleMutationEnabled", group: "capabilities", label: "Schedule mutation", help: "Allow programmatic schedule add/remove at runtime.", type: "boolean", envVars: ["PADDOCK_SCHEDULE_MUTATION"], default: false, editable: true, coerce: asBool },
260
260
  { key: "hooksMcpEnabled", group: "capabilities", label: "Hooks MCP", help: "Let agents declare/edit their own event hooks (needs self-MCP write).", type: "boolean", envVars: ["PADDOCK_HOOKS_MCP"], default: false, editable: true, coerce: asBool },
@@ -264,7 +264,6 @@ export const FIELDS = [
264
264
  { key: "recovery.autoReDrive", group: "recovery", label: "Auto re-drive", type: "boolean", envVars: ["PADDOCK_RECOVERY_AUTODRIVE"], default: DEFAULT_RECOVERY.autoReDrive, editable: true, coerce: asBool },
265
265
  { key: "recovery.debounceMs", group: "recovery", label: "Debounce (ms)", type: "number", envVars: ["PADDOCK_RECOVERY_DEBOUNCE_MS"], default: DEFAULT_RECOVERY.debounceMs, editable: true, coerce: nonNegInt },
266
266
  { key: "recovery.maxRetries", group: "recovery", label: "Max retries", type: "number", envVars: ["PADDOCK_RECOVERY_MAX_RETRIES"], default: DEFAULT_RECOVERY.maxRetries, editable: true, coerce: nonNegInt },
267
- { key: "recovery.limboTimeoutMs", group: "recovery", label: "Limbo timeout (ms)", type: "number", envVars: ["PADDOCK_RECOVERY_LIMBO_MS"], default: DEFAULT_RECOVERY.limboTimeoutMs, editable: true, coerce: nonNegInt },
268
267
  // Attachments (issue #328).
269
268
  { key: "attachments.enabled", group: "attachments", label: "Enabled", type: "boolean", envVars: ["PADDOCK_ATTACHMENTS_ENABLED"], default: DEFAULT_ATTACHMENTS.enabled, editable: true, coerce: asBool },
270
269
  { key: "attachments.maxFileSizeMb", group: "attachments", label: "Max file size (MB)", type: "number", envVars: ["PADDOCK_ATTACHMENTS_MAX_FILE_SIZE_MB"], default: DEFAULT_ATTACHMENTS.maxFileSizeMb, editable: true, coerce: posInt },
@@ -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;