harnery 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +1 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +6 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +2 -3
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/harness.d.ts +11 -0
  23. package/dist/commands/harness.d.ts.map +1 -0
  24. package/dist/commands/harness.js +118 -0
  25. package/dist/commands/presence.d.ts +9 -4
  26. package/dist/commands/presence.d.ts.map +1 -1
  27. package/dist/commands/presence.js +88 -5
  28. package/dist/commands/relay.d.ts +9 -0
  29. package/dist/commands/relay.d.ts.map +1 -0
  30. package/dist/commands/relay.js +143 -0
  31. package/dist/commands/sync.d.ts.map +1 -1
  32. package/dist/commands/sync.js +5 -0
  33. package/dist/commands/workflow.d.ts.map +1 -1
  34. package/dist/commands/workflow.js +53 -17
  35. package/dist/core/agents/canonical-emit.d.ts +15 -0
  36. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  37. package/dist/core/agents/canonical-emit.js +41 -3
  38. package/dist/core/agents/cli.js +4 -2
  39. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/prompt-context.js +44 -3
  41. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  42. package/dist/core/agents/render/session-context.js +26 -1
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +4 -37
  45. package/dist/core/agents/session-events.d.ts.map +1 -1
  46. package/dist/core/agents/session-events.js +16 -43
  47. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  48. package/dist/core/agents/state/heartbeat-projector.js +11 -2
  49. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  50. package/dist/core/agents/state/heartbeat-writer.js +3 -3
  51. package/dist/core/agents/state/names.d.ts +38 -3
  52. package/dist/core/agents/state/names.d.ts.map +1 -1
  53. package/dist/core/agents/state/names.js +46 -7
  54. package/dist/core/agents/state/pidmap.d.ts +5 -1
  55. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  56. package/dist/core/agents/state/pidmap.js +58 -2
  57. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  58. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  59. package/dist/core/agents/state/stale-sweep.js +6 -6
  60. package/dist/core/config.d.ts +68 -11
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +191 -25
  63. package/dist/core/context/index.d.ts +144 -0
  64. package/dist/core/context/index.d.ts.map +1 -0
  65. package/dist/core/context/index.js +380 -0
  66. package/dist/core/harnesses/bench.d.ts +29 -0
  67. package/dist/core/harnesses/bench.d.ts.map +1 -0
  68. package/dist/core/harnesses/bench.js +151 -0
  69. package/dist/core/harnesses/index.d.ts +9 -0
  70. package/dist/core/harnesses/index.d.ts.map +1 -0
  71. package/dist/core/harnesses/index.js +4 -0
  72. package/dist/core/harnesses/profiles.d.ts +62 -0
  73. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  74. package/dist/core/harnesses/profiles.js +115 -0
  75. package/dist/core/harnesses/registry.d.ts +15 -0
  76. package/dist/core/harnesses/registry.d.ts.map +1 -0
  77. package/dist/core/harnesses/registry.js +113 -0
  78. package/dist/core/harnesses/types.d.ts +64 -0
  79. package/dist/core/harnesses/types.d.ts.map +1 -0
  80. package/dist/core/harnesses/types.js +20 -0
  81. package/dist/core/hooks/cli.js +280 -63
  82. package/dist/core/hooks/events/schema.d.ts +38 -1
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  85. package/dist/core/hooks/harness/events.js +3 -0
  86. package/dist/core/hooks/harness/parse.d.ts +1 -1
  87. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  88. package/dist/core/hooks/harness/parse.js +4 -0
  89. package/dist/core/presence/blob.d.ts +40 -0
  90. package/dist/core/presence/blob.d.ts.map +1 -0
  91. package/dist/core/presence/blob.js +91 -0
  92. package/dist/core/presence/git.d.ts +68 -0
  93. package/dist/core/presence/git.d.ts.map +1 -0
  94. package/dist/core/presence/git.js +173 -0
  95. package/dist/core/presence/index.d.ts +74 -0
  96. package/dist/core/presence/index.d.ts.map +1 -0
  97. package/dist/core/presence/index.js +224 -0
  98. package/dist/core/presence/relay-client.d.ts +39 -0
  99. package/dist/core/presence/relay-client.d.ts.map +1 -0
  100. package/dist/core/presence/relay-client.js +295 -0
  101. package/dist/core/presence/relay-protocol.d.ts +94 -0
  102. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  103. package/dist/core/presence/relay-protocol.js +169 -0
  104. package/dist/core/workflow/billing.d.ts +1 -1
  105. package/dist/core/workflow/billing.d.ts.map +1 -1
  106. package/dist/core/workflow/billing.js +7 -1
  107. package/dist/core/workflow/engine.d.ts +5 -0
  108. package/dist/core/workflow/engine.d.ts.map +1 -1
  109. package/dist/core/workflow/engine.js +164 -16
  110. package/dist/core/workflow/harnesses.d.ts +3 -3
  111. package/dist/core/workflow/harnesses.d.ts.map +1 -1
  112. package/dist/core/workflow/harnesses.js +8 -17
  113. package/dist/core/workflow/index.d.ts +5 -0
  114. package/dist/core/workflow/index.d.ts.map +1 -0
  115. package/dist/core/workflow/index.js +3 -0
  116. package/dist/core/workflow/proof.d.ts +42 -0
  117. package/dist/core/workflow/proof.d.ts.map +1 -0
  118. package/dist/core/workflow/proof.js +342 -0
  119. package/dist/core/workflow/spawn-claude.d.ts +4 -1
  120. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  121. package/dist/core/workflow/spawn-claude.js +39 -18
  122. package/dist/core/workflow/spawn-codex.d.ts +4 -1
  123. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  124. package/dist/core/workflow/spawn-codex.js +44 -22
  125. package/dist/core/workflow/spawn-cursor.d.ts +4 -1
  126. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  127. package/dist/core/workflow/spawn-cursor.js +35 -16
  128. package/dist/core/workflow/types.d.ts +156 -2
  129. package/dist/core/workflow/types.d.ts.map +1 -1
  130. package/dist/core/workflow/types.js +1 -1
  131. package/dist/lib/browser/client.d.ts +16 -0
  132. package/dist/lib/browser/client.d.ts.map +1 -1
  133. package/dist/lib/browser/client.js +22 -0
  134. package/dist/lib/browser/index.d.ts +1 -0
  135. package/dist/lib/browser/index.d.ts.map +1 -1
  136. package/dist/lib/browser/index.js +1 -0
  137. package/dist/lib/browser/launch-args.d.ts +21 -0
  138. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  139. package/dist/lib/browser/launch-args.js +32 -0
  140. package/dist/lib/claude-desktop.d.ts +105 -0
  141. package/dist/lib/claude-desktop.d.ts.map +1 -0
  142. package/dist/lib/claude-desktop.js +217 -0
  143. package/dist/lib/docs-lint.d.ts.map +1 -1
  144. package/dist/lib/docs-lint.js +5 -2
  145. package/dist/lib/identities/assume.d.ts +51 -0
  146. package/dist/lib/identities/assume.d.ts.map +1 -0
  147. package/dist/lib/identities/assume.js +274 -0
  148. package/dist/lib/identities/index.d.ts +7 -7
  149. package/dist/lib/identities/index.d.ts.map +1 -1
  150. package/dist/lib/identities/index.js +24 -24
  151. package/dist/lib/instructions/templates.d.ts.map +1 -1
  152. package/dist/lib/instructions/templates.js +15 -2
  153. package/package.json +22 -6
  154. package/schemas/config.schema.json +88 -19
  155. package/src/commander.ts +6 -0
  156. package/src/commands/agents.ts +280 -14
  157. package/src/commands/backup.ts +17 -16
  158. package/src/commands/browse.ts +32 -0
  159. package/src/commands/claude-desktop.ts +215 -0
  160. package/src/commands/context.ts +174 -1
  161. package/src/commands/doctor.ts +2 -3
  162. package/src/commands/eml.ts +22 -4
  163. package/src/commands/harness.ts +147 -0
  164. package/src/commands/presence.ts +111 -5
  165. package/src/commands/relay.ts +160 -0
  166. package/src/commands/sync.ts +4 -0
  167. package/src/commands/workflow.ts +62 -19
  168. package/src/core/agents/canonical-emit.ts +41 -3
  169. package/src/core/agents/cli.ts +4 -2
  170. package/src/core/agents/render/prompt-context.ts +48 -3
  171. package/src/core/agents/render/session-context.ts +27 -1
  172. package/src/core/agents/rules/commit-conflict.ts +4 -33
  173. package/src/core/agents/session-events.ts +15 -39
  174. package/src/core/agents/state/heartbeat-projector.ts +10 -1
  175. package/src/core/agents/state/heartbeat-writer.ts +3 -2
  176. package/src/core/agents/state/names.ts +68 -9
  177. package/src/core/agents/state/pidmap.ts +59 -2
  178. package/src/core/agents/state/stale-sweep.ts +6 -11
  179. package/src/core/config.ts +248 -24
  180. package/src/core/context/index.ts +575 -0
  181. package/src/core/harnesses/bench.ts +214 -0
  182. package/src/core/harnesses/index.ts +31 -0
  183. package/src/core/harnesses/profiles.ts +141 -0
  184. package/src/core/harnesses/registry.ts +140 -0
  185. package/src/core/harnesses/types.ts +90 -0
  186. package/src/core/hooks/cli.ts +331 -62
  187. package/src/core/hooks/events/schema.ts +68 -0
  188. package/src/core/hooks/harness/events.ts +3 -0
  189. package/src/core/hooks/harness/parse.ts +7 -1
  190. package/src/core/presence/blob.ts +125 -0
  191. package/src/core/presence/git.ts +191 -0
  192. package/src/core/presence/index.ts +274 -0
  193. package/src/core/presence/relay-client.ts +322 -0
  194. package/src/core/presence/relay-protocol.ts +246 -0
  195. package/src/core/workflow/billing.ts +8 -2
  196. package/src/core/workflow/engine.ts +182 -16
  197. package/src/core/workflow/harnesses.ts +14 -19
  198. package/src/core/workflow/index.ts +43 -0
  199. package/src/core/workflow/proof.ts +434 -0
  200. package/src/core/workflow/spawn-claude.ts +39 -20
  201. package/src/core/workflow/spawn-codex.ts +47 -28
  202. package/src/core/workflow/spawn-cursor.ts +37 -18
  203. package/src/core/workflow/types.ts +176 -2
  204. package/src/lib/browser/client.ts +37 -0
  205. package/src/lib/browser/index.ts +1 -0
  206. package/src/lib/browser/launch-args.ts +33 -0
  207. package/src/lib/claude-desktop.ts +293 -0
  208. package/src/lib/docs-lint.ts +5 -2
  209. package/src/lib/identities/assume.ts +363 -0
  210. package/src/lib/identities/index.ts +28 -24
  211. package/src/lib/instructions/templates.ts +16 -2
@@ -1,20 +1,24 @@
1
1
  /**
2
- * `.harnery/config.jsonc` reader: the host-project config the coord/hook layer
3
- * consults when it can't see the consumer CLI's own process.
2
+ * Harnery config reader: the settings the coord/hook layer consults when it
3
+ * can't see the consumer CLI's own process.
4
4
  *
5
- * The single field this module owns today is `binName`: the host CLI's binary
6
- * name (e.g. `myapp`), used in user-facing strings the agent reads and runs:
7
- * council prompts, end-of-turn nudges, command help/errors. The coord binaries
8
- * (`agent-hook`, `agent-coord`) and the web UI run as harnery itself, so they
9
- * have no other way to learn the consumer's bin name; `harn init` stamps it here
10
- * for them to read back. The `files` deny/override section is parsed separately
11
- * by `web/lib/files.ts`.
5
+ * Two layers, project-over-user (project wins field-by-field):
6
+ * 1. `~/.config/harnery/config.jsonc` — user-global base (optional)
7
+ * 2. `<project-root>/.harnery/config.jsonc` — project override (authoritative)
12
8
  *
13
- * Dependency-free (no jsonc npm dep) so it runs on both Bun and Node, and so the
14
- * ADR-009 vendored copies stay portable.
9
+ * Fields owned here: `binName` (host CLI name for agent-facing strings),
10
+ * `hooksSetupHint`, `tools`, `workflow`, `skills`, `presence`, plus the tunable
11
+ * `coord` (heartbeat freshness), `backup` (restic repo/password/prune policy),
12
+ * and `sync` (rclone remote/prefix) sections. The `files` deny/override section
13
+ * is parsed separately by `web/lib/files.ts`.
14
+ *
15
+ * Env vars and CLI flags override any config value per invocation (each accessor
16
+ * documents its own precedence). Dependency-free (no jsonc npm dep) so it runs on
17
+ * both Bun and Node, and so the ADR-009 vendored copies stay portable.
15
18
  */
16
19
 
17
20
  import { readFileSync, statSync } from "node:fs";
21
+ import { homedir } from "node:os";
18
22
  import { join } from "node:path";
19
23
  import { coordEnv } from "../lib/env.ts";
20
24
  import { findCoordRoot } from "./hooks/resolve/coord-root.ts";
@@ -22,6 +26,9 @@ import { findCoordRoot } from "./hooks/resolve/coord-root.ts";
22
26
  /** The standalone CLI's bin name: the resolution floor when nothing else is set. */
23
27
  export const DEFAULT_BIN_NAME = "harn";
24
28
 
29
+ /** Heartbeat-freshness default (seconds): the sweep window when nothing overrides it. */
30
+ export const DEFAULT_FRESHNESS_SECS = 600;
31
+
25
32
  interface HarneryConfig {
26
33
  /** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "acme"). */
27
34
  binName?: string;
@@ -46,6 +53,42 @@ interface HarneryConfig {
46
53
  * scrubbed from child envs) without anyone having to remember the flag.
47
54
  */
48
55
  workflow?: { subscriptionOnly?: boolean };
56
+ /**
57
+ * Cross-machine presence (ADR 0016). `{ enabled: false }` opts a repo out of
58
+ * the git-refs transport (publishing `refs/harnery/presence/<machine>` to
59
+ * origin + fetching peers'). Default is ON when an origin remote exists —
60
+ * the zero-config story — and every operation is fail-silent.
61
+ *
62
+ * `relay` (optional) is the live upgrade: a wss:// URL of a presence relay
63
+ * (the reference public one is wss://relay.harnery.com; self-hosters run
64
+ * `harn relay serve` or deploy relay/worker/ to their own Cloudflare
65
+ * account). When set, hooks keep a per-machine daemon connected to the
66
+ * relay for seconds-latency presence; the git-refs transport stays on as
67
+ * the floor. Unset → git-refs only.
68
+ */
69
+ presence?: { enabled?: boolean; relay?: string };
70
+ /**
71
+ * Coord-layer tunables. `freshness_seconds` is the heartbeat age above which
72
+ * the sweeper prunes an agent (default 600). Read via `coordFreshnessSeconds()`.
73
+ */
74
+ coord?: { freshness_seconds?: number };
75
+ /**
76
+ * `harn backup` (restic) defaults: `repo` path/URL, `password_file`, and the
77
+ * `keep_daily`/`keep_weekly`/`keep_monthly` prune policy. Read via `backupConfig()`.
78
+ */
79
+ backup?: {
80
+ repo?: string;
81
+ password_file?: string;
82
+ keep_daily?: number;
83
+ keep_weekly?: number;
84
+ keep_monthly?: number;
85
+ };
86
+ /**
87
+ * `harn sync` (rclone) defaults: the `remote` name and `prefix` subpath. Read
88
+ * via `syncJsoncConfig()`. `harn sync init` also persists these to
89
+ * `~/.config/harnery/sync.json`, which is consulted as a lower-precedence fallback.
90
+ */
91
+ sync?: { remote?: string; prefix?: string };
49
92
  [k: string]: unknown;
50
93
  }
51
94
 
@@ -89,29 +132,92 @@ export function stripJsonComments(input: string): string {
89
132
  return out;
90
133
  }
91
134
 
92
- // mtime-keyed per-process cache: a stat is cheap, a parse on every render isn't.
93
- let cache: { root: string; mtimeMs: number; cfg: HarneryConfig } | null = null;
135
+ /**
136
+ * The user-global config file, the lower-precedence base under the project file.
137
+ * Honors `XDG_CONFIG_HOME` (falling back to `~/.config`) per the XDG base-dir spec.
138
+ */
139
+ function userConfigPath(): string {
140
+ const xdg = process.env.XDG_CONFIG_HOME;
141
+ const base = xdg?.trim() ? xdg : join(homedir(), ".config");
142
+ return join(base, "harnery", "config.jsonc");
143
+ }
94
144
 
95
- function readConfig(root: string): HarneryConfig {
96
- const p = join(root, ".harnery", "config.jsonc");
97
- let mtimeMs = -1;
145
+ /** File mtime in ms, or -1 when the file can't be stat'd (missing). */
146
+ function statMtime(p: string): number {
98
147
  try {
99
- mtimeMs = statSync(p).mtimeMs;
148
+ return statSync(p).mtimeMs;
100
149
  } catch {
101
- /* missing → -1; cache still valid until the file appears */
150
+ return -1;
102
151
  }
103
- if (cache && cache.root === root && cache.mtimeMs === mtimeMs) return cache.cfg;
104
- let cfg: HarneryConfig = {};
152
+ }
153
+
154
+ /** Parse one JSONC config file to an object; missing/unparseable → `{}`. */
155
+ function parseConfigFile(p: string): HarneryConfig {
105
156
  try {
106
157
  const parsed = JSON.parse(stripJsonComments(readFileSync(p, "utf8"))) as HarneryConfig | null;
107
- if (parsed && typeof parsed === "object") cfg = parsed;
158
+ if (parsed && typeof parsed === "object") return parsed;
108
159
  } catch {
109
- /* missing or unparseable → defaults (the files-section resolver fails loud; bin name is non-critical) */
160
+ /* missing or unparseable → defaults (files-section resolver fails loud; the rest is non-critical) */
161
+ }
162
+ return {};
163
+ }
164
+
165
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
166
+ return typeof v === "object" && v !== null && !Array.isArray(v);
167
+ }
168
+
169
+ /**
170
+ * Deep-merge `override` onto `base` (override wins), recursing into plain
171
+ * objects only so a project that sets just `web.port` doesn't wipe a
172
+ * user-global `web.bind`. Arrays + scalars replace wholesale.
173
+ */
174
+ function mergeConfig(base: HarneryConfig, override: HarneryConfig): HarneryConfig {
175
+ const out: Record<string, unknown> = { ...base };
176
+ for (const [k, v] of Object.entries(override)) {
177
+ const prev = out[k];
178
+ out[k] = isPlainObject(prev) && isPlainObject(v) ? mergeConfig(prev, v) : v;
110
179
  }
111
- cache = { root, mtimeMs, cfg };
180
+ return out as HarneryConfig;
181
+ }
182
+
183
+ // mtime-keyed per-process cache (both layers): a stat is cheap, a parse on every render isn't.
184
+ let cache: { root: string; projMtime: number; userMtime: number; cfg: HarneryConfig } | null = null;
185
+
186
+ /**
187
+ * The effective config for `root`: user-global (`~/.config/harnery/config.jsonc`)
188
+ * as the base, the project file (`<root>/.harnery/config.jsonc`) merged on top.
189
+ * Project values win field-by-field.
190
+ */
191
+ function readConfig(root: string): HarneryConfig {
192
+ const projPath = join(root, ".harnery", "config.jsonc");
193
+ const userPath = userConfigPath();
194
+ const projMtime = statMtime(projPath);
195
+ const userMtime = statMtime(userPath);
196
+ if (
197
+ cache &&
198
+ cache.root === root &&
199
+ cache.projMtime === projMtime &&
200
+ cache.userMtime === userMtime
201
+ ) {
202
+ return cache.cfg;
203
+ }
204
+ const user = userMtime === -1 ? {} : parseConfigFile(userPath);
205
+ const project = projMtime === -1 ? {} : parseConfigFile(projPath);
206
+ const cfg = mergeConfig(user, project);
207
+ cache = { root, projMtime, userMtime, cfg };
112
208
  return cfg;
113
209
  }
114
210
 
211
+ /**
212
+ * The PROJECT config file only — no user-global merge. `pinnedBinName` uses this
213
+ * so a user-global `binName` can never masquerade as a deliberate project pin
214
+ * (the pin guards committed, public surfaces; see `pinnedBinName`).
215
+ */
216
+ function readProjectConfig(root: string): HarneryConfig {
217
+ const p = join(root, ".harnery", "config.jsonc");
218
+ return statMtime(p) === -1 ? {} : parseConfigFile(p);
219
+ }
220
+
115
221
  /**
116
222
  * Resolve the host CLI's bin name for user-facing strings. Precedence:
117
223
  * 1. `HARNERY_BIN` env (explicit per-process override)
@@ -141,7 +247,7 @@ export function resolveBinName(coordRoot?: string | null): string {
141
247
  * CLI would otherwise stamp the host's name into public, committed surfaces).
142
248
  */
143
249
  export function pinnedBinName(projectRoot: string): string | null {
144
- const binName = readConfig(projectRoot).binName;
250
+ const binName = readProjectConfig(projectRoot).binName;
145
251
  return typeof binName === "string" && binName.trim() ? binName.trim() : null;
146
252
  }
147
253
 
@@ -191,3 +297,121 @@ export function workflowSubscriptionOnly(coordRoot?: string | null): boolean {
191
297
  if (!root) return false;
192
298
  return readConfig(root).workflow?.subscriptionOnly === true;
193
299
  }
300
+
301
+ /**
302
+ * Whether cross-machine presence (ADR 0016) is enabled for this repo.
303
+ * Default ON — the transport itself additionally gates on an origin remote
304
+ * existing and fails silent everywhere. Opt out via
305
+ * `.harnery/config.jsonc` `{ "presence": { "enabled": false } }`;
306
+ * `HARNERY_PRESENCE=1|0` overrides per process.
307
+ */
308
+ export function presenceEnabled(coordRoot?: string | null): boolean {
309
+ const env = coordEnv("PRESENCE");
310
+ if (env === "1") return true;
311
+ if (env === "0") return false;
312
+ const root = coordRoot ?? findCoordRoot();
313
+ if (!root) return false;
314
+ return readConfig(root).presence?.enabled !== false;
315
+ }
316
+
317
+ /**
318
+ * The presence relay URL for this repo, or null when the relay transport is
319
+ * not configured (git-refs only). `HARNERY_PRESENCE_RELAY` overrides per
320
+ * process (empty string or "0" disables). Requires `presenceEnabled()` to be
321
+ * true — a disabled presence section disables the relay too.
322
+ */
323
+ export function presenceRelayUrl(coordRoot?: string | null): string | null {
324
+ const root = coordRoot ?? findCoordRoot();
325
+ if (!presenceEnabled(root)) return null;
326
+ const env = coordEnv("PRESENCE_RELAY");
327
+ if (env !== undefined && env !== null) {
328
+ const t = env.trim();
329
+ return t && t !== "0" ? t : null;
330
+ }
331
+ if (!root) return null;
332
+ const relay = readConfig(root).presence?.relay;
333
+ return typeof relay === "string" && relay.trim() ? relay.trim() : null;
334
+ }
335
+
336
+ /** A positive integer from `v`, else `fallback`. Floors non-integer numbers. */
337
+ function posIntOr(v: unknown, fallback: number): number {
338
+ const n = typeof v === "number" ? v : Number.NaN;
339
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
340
+ }
341
+
342
+ /**
343
+ * The heartbeat-freshness window (seconds): the age above which the sweeper
344
+ * prunes an agent, and the cutoff the `agents` surface uses to fold stale peers.
345
+ * Precedence:
346
+ * 1. `HARNERY_AGENT_COORD_FRESHNESS` env (canonical), or `HARNERY_AGENT_FRESHNESS` (legacy alias)
347
+ * 2. `.harnery/config.jsonc` `coord.freshness_seconds`
348
+ * 3. `600` (10 minutes)
349
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
350
+ */
351
+ export function coordFreshnessSeconds(coordRoot?: string | null): number {
352
+ const env = coordEnv("AGENT_COORD_FRESHNESS") ?? coordEnv("AGENT_FRESHNESS");
353
+ if (env !== undefined) {
354
+ const n = Number.parseInt(env, 10);
355
+ if (Number.isFinite(n) && n > 0) return n;
356
+ }
357
+ const root = coordRoot ?? findCoordRoot();
358
+ if (root) return posIntOr(readConfig(root).coord?.freshness_seconds, DEFAULT_FRESHNESS_SECS);
359
+ return DEFAULT_FRESHNESS_SECS;
360
+ }
361
+
362
+ /** Resolved `harn backup` defaults (restic repo/password + prune policy). */
363
+ export interface BackupConfig {
364
+ repo: string;
365
+ passwordFile: string;
366
+ keepDaily: number;
367
+ keepWeekly: number;
368
+ keepMonthly: number;
369
+ }
370
+
371
+ /**
372
+ * `harn backup` (restic) defaults. Per field, precedence is env → config → built-in:
373
+ * repo: `HARNERY_RESTIC_REPO` → `backup.repo` → `~/.cache/harnery/restic-repo`
374
+ * passwordFile: `HARNERY_RESTIC_PASSWORD_FILE` → `backup.password_file` → `~/.config/harnery/restic-password`
375
+ * keepDaily/Weekly/Monthly: `backup.keep_*` → 7 / 4 / 6
376
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
377
+ */
378
+ export function backupConfig(coordRoot?: string | null): BackupConfig {
379
+ const home = homedir();
380
+ const root = coordRoot ?? findCoordRoot();
381
+ const b = root ? (readConfig(root).backup ?? {}) : {};
382
+ const cfgStr = (v: unknown): string | undefined =>
383
+ typeof v === "string" && v.trim() ? v.trim() : undefined;
384
+ const repo =
385
+ coordEnv("RESTIC_REPO") ?? cfgStr(b.repo) ?? join(home, ".cache", "harnery", "restic-repo");
386
+ const passwordFile =
387
+ coordEnv("RESTIC_PASSWORD_FILE") ??
388
+ cfgStr(b.password_file) ??
389
+ join(home, ".config", "harnery", "restic-password");
390
+ return {
391
+ repo,
392
+ passwordFile,
393
+ keepDaily: posIntOr(b.keep_daily, 7),
394
+ keepWeekly: posIntOr(b.keep_weekly, 4),
395
+ keepMonthly: posIntOr(b.keep_monthly, 6),
396
+ };
397
+ }
398
+
399
+ /**
400
+ * `harn sync` (rclone) remote/prefix from `.harnery/config.jsonc` `sync`, or null
401
+ * when unset. This is the config-file layer only; `harn sync` consults env
402
+ * (`HARNERY_SYNC_REMOTE`/`_PREFIX`) first and the `~/.config/harnery/sync.json`
403
+ * file (written by `harn sync init`) as a lower-precedence fallback.
404
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
405
+ */
406
+ export function syncJsoncConfig(
407
+ coordRoot?: string | null,
408
+ ): { remote: string; prefix: string } | null {
409
+ const root = coordRoot ?? findCoordRoot();
410
+ if (!root) return null;
411
+ const s = readConfig(root).sync ?? {};
412
+ if (typeof s.remote === "string" && s.remote.trim()) {
413
+ const prefix = typeof s.prefix === "string" && s.prefix.trim() ? s.prefix.trim() : "harnery";
414
+ return { remote: s.remote.trim(), prefix };
415
+ }
416
+ return null;
417
+ }