@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.992

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 (38) hide show
  1. package/changelog.json +12 -0
  2. package/deploy/unraid/Dockerfile +15 -2
  3. package/deploy/unraid/README.txt +44 -9
  4. package/deploy/unraid/github_known_hosts +3 -0
  5. package/deploy/unraid/ouro-sync-ssh +98 -0
  6. package/deploy/unraid/ouro-sync.sudoers +3 -0
  7. package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
  8. package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
  9. package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
  10. package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
  11. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  12. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
  13. package/deploy/unraid/sanctuary.xml +2 -1
  14. package/dist/heart/config.js +2 -1
  15. package/dist/heart/daemon/agent-discovery.js +5 -1
  16. package/dist/heart/daemon/agent-home.js +67 -31
  17. package/dist/heart/daemon/boot-sync-probe.js +36 -10
  18. package/dist/heart/daemon/cli-exec.js +79 -20
  19. package/dist/heart/daemon/cli-help.js +12 -0
  20. package/dist/heart/daemon/cli-parse.js +14 -0
  21. package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
  22. package/dist/heart/daemon/container-spec-auditor.js +18 -2
  23. package/dist/heart/daemon/daemon-entry.js +22 -0
  24. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  25. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +21 -5
  26. package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
  27. package/dist/heart/daemon/sanctuary-package-management.js +10 -0
  28. package/dist/heart/sync-lock.js +431 -0
  29. package/dist/heart/sync.js +1252 -399
  30. package/dist/mind/prompt.js +92 -19
  31. package/dist/repertoire/guardrails.js +285 -3
  32. package/dist/repertoire/tools-bundle.js +107 -185
  33. package/dist/repertoire/tools.js +5 -1
  34. package/dist/senses/pipeline.js +37 -3
  35. package/dist/senses/private-runtime.js +1 -6
  36. package/dist/senses/voice/twilio-phone.js +16 -16
  37. package/npm-shrinkwrap.json +2 -2
  38. package/package.json +1 -1
@@ -61,6 +61,7 @@
61
61
  }
62
62
  },
63
63
  "sync": {
64
- "enabled": false
64
+ "enabled": true,
65
+ "branch": "main"
65
66
  }
66
67
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.990",
2
+ "runtimeVersion": "0.1.0-alpha.992",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -2,7 +2,7 @@
2
2
  "version": 2,
3
3
  "profiles": {
4
4
  "sanctuary-owner": {
5
- "version": 17,
5
+ "version": 18,
6
6
  "contextScopes": [
7
7
  "household.status",
8
8
  "household.policy",
@@ -38,6 +38,8 @@
38
38
  "steward_policy_manage",
39
39
  "house_sweep",
40
40
  "house_digest_send",
41
+ "bundle_push",
42
+ "bundle_pull_rebase",
41
43
  "send_message",
42
44
  "ponder",
43
45
  "rest",
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.990</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.992</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -26,4 +26,5 @@
26
26
  <Config Name="Privileged event spool" Target="/run/ouro-events" Default="/boot/config/custom/ouro-events/spool" Mode="ro" Description="Root-owned detector envelopes, read-only inside the Butler" Type="Path" Display="always" Required="true" Mask="false">/boot/config/custom/ouro-events/spool</Config>
27
27
  <Config Name="Root authority socket" Target="/run/ouro-authority" Default="/run/ouro-authority" Mode="ro" Description="Root-owned gateway socket and public issuer pins only" Type="Path" Display="always" Required="true" Mask="false">/run/ouro-authority</Config>
28
28
  <Config Name="Operator trust (read-only)" Target="/etc/ouro/trust/sanctuary" Default="/mnt/user/appdata/ouro-butler/trust/sanctuary" Mode="ro" Description="Root-owned grants the Butler honours (delegated commands, escalation). Read-only inside the Butler; only the host operator can change it" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/trust/sanctuary</Config>
29
+ <Config Name="Bundle sync key (read-only)" Target="/run/ouro-sync" Default="/mnt/user/appdata/ouro-butler/sync" Mode="ro" Description="Deploy key the Butler's bundle sync pushes with, owned by 10002:10002 mode 0400 so only the sync wrapper reads it. Read-only inside the Butler" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/sync</Config>
29
30
  </Container>
@@ -342,11 +342,12 @@ function getSyncConfig() {
342
342
  return {
343
343
  enabled: agentConfig.sync?.enabled ?? false,
344
344
  remote: agentConfig.sync?.remote ?? "origin",
345
+ branch: agentConfig.sync?.branch ?? "main",
345
346
  };
346
347
  }
347
348
  catch {
348
349
  /* v8 ignore next -- defensive: loadAgentConfig failure in test/bootstrap @preserve */
349
- return { enabled: false, remote: "origin" };
350
+ return { enabled: false, remote: "origin", branch: "main" };
350
351
  }
351
352
  }
352
353
  function getOpenAIEmbeddingsApiKey() {
@@ -315,6 +315,7 @@ function listBundleSyncRows(options = {}) {
315
315
  const configPath = path.join(bundleRoot, "agent.json");
316
316
  let enabled = false;
317
317
  let remote = "origin";
318
+ let branch = "main";
318
319
  try {
319
320
  const raw = readFileSync(configPath, "utf-8");
320
321
  const parsed = JSON.parse(raw);
@@ -324,12 +325,15 @@ function listBundleSyncRows(options = {}) {
324
325
  if (typeof parsed.sync.remote === "string" && parsed.sync.remote.length > 0) {
325
326
  remote = parsed.sync.remote;
326
327
  }
328
+ if (typeof parsed.sync.branch === "string" && parsed.sync.branch.length > 0) {
329
+ branch = parsed.sync.branch;
330
+ }
327
331
  }
328
332
  }
329
333
  catch {
330
334
  // Best-effort: bundle without readable config still gets a row with defaults
331
335
  }
332
- const row = { agent, enabled, remote };
336
+ const row = { agent, enabled, remote, branch };
333
337
  if (enabled) {
334
338
  // Only meaningful when sync is enabled — we don't care about disabled bundles'
335
339
  // git state. Check .git presence before attempting any git invocation.
@@ -8,12 +8,14 @@
8
8
  * module writes it (`ouro move <agent> here`) and checks a running daemon
9
9
  * against it.
10
10
  *
11
- * A move is a compare-and-swap on the bundle's remote. It starts only from a
12
- * bundle that is exactly at its upstream (nothing unpushed, agent.json clean),
13
- * commits only agent.json and pushes. A fast-forward push succeeds only if
14
- * nobody pushed in between. When the push is rejected, the move is undone; if
15
- * the upstream lease changed meanwhile, another machine won and the move
16
- * stops, otherwise it is retried once on the new upstream.
11
+ * A move is a compare-and-swap on the bundle's remote. It runs under the
12
+ * bundle sync lock, honours a sync pause, and names `<remote>` and
13
+ * `sync.branch` explicitly (never `@{u}`). It starts only from a bundle that
14
+ * is exactly at `<remote>/<branch>` (nothing unpushed, agent.json clean),
15
+ * commits only agent.json and pushes `HEAD:<branch>`. A fast-forward push
16
+ * succeeds only if nobody pushed in between. When the push is refused, the
17
+ * move is undone; if the remote's lease changed meanwhile, another machine won
18
+ * and the move stops, otherwise it is retried once on the new remote state.
17
19
  */
18
20
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
21
  if (k2 === undefined) k2 = k;
@@ -57,13 +59,20 @@ const fs = __importStar(require("fs"));
57
59
  const path = __importStar(require("path"));
58
60
  const child_process_1 = require("child_process");
59
61
  const runtime_1 = require("../../nerves/runtime");
62
+ const sync_1 = require("../sync");
60
63
  const agent_discovery_1 = require("./agent-discovery");
64
+ const SYNC_NETWORK = {
65
+ fetch: (agentRoot, config) => (0, sync_1.fetchRemote)(agentRoot, config),
66
+ push: (agentRoot, config) => (0, sync_1.pushHeadToBranch)(agentRoot, config),
67
+ };
61
68
  const MOVE_ATTEMPTS = 2;
69
+ const MOVE_LOCK_WAIT_MS = 10_000;
62
70
  function readSyncSettings(parsed) {
63
71
  const sync = parsed.sync;
64
72
  return {
65
73
  enabled: sync?.enabled === true,
66
74
  remote: typeof sync?.remote === "string" && sync.remote.trim() ? sync.remote : "origin",
75
+ branch: typeof sync?.branch === "string" && sync.branch.trim() ? sync.branch : "main",
67
76
  };
68
77
  }
69
78
  function errorText(error) {
@@ -87,11 +96,12 @@ function recordedPushError(agentRoot) {
87
96
  }
88
97
  }
89
98
  /** Record this machine as the agent's home in its synced agent.json. */
90
- function moveAgentHomeHere(options) {
99
+ async function moveAgentHomeHere(options) {
91
100
  const { agent, machineId, machineName } = options;
92
101
  const agentRoot = path.join(options.bundlesRoot, `${agent}.ouro`);
93
102
  const configPath = path.join(agentRoot, "agent.json");
94
103
  const git = options.execFileSync ?? child_process_1.execFileSync;
104
+ const network = options.network ?? SYNC_NETWORK;
95
105
  const now = options.now ?? (() => new Date());
96
106
  const run = (args) => git("git", args, { cwd: agentRoot, stdio: "pipe", timeout: 30_000 }).toString().trim();
97
107
  (0, runtime_1.emitNervesEvent)({
@@ -140,35 +150,47 @@ function moveAgentHomeHere(options) {
140
150
  const why = sync.enabled ? "the bundle is not a git repo" : "bundle sync is off";
141
151
  return finish({ ok: true, changed: true, message: `${movedMessage(current)}; ${why}, so only this machine sees the change` });
142
152
  }
153
+ const pausedRefusal = (until) => refuse(`${agent}'s bundle sync is paused until ${until}, so nothing changed; run \`ouro move ${agent} here\` again after that`);
154
+ const pausedUntil = (0, sync_1.syncPausedUntil)(agentRoot);
155
+ if (pausedUntil !== null)
156
+ return pausedRefusal(pausedUntil);
157
+ const lock = await (0, sync_1.holdSyncLock)(agentRoot, options.lockWaitMs ?? MOVE_LOCK_WAIT_MS);
158
+ if ("busy" in lock) {
159
+ return refuse(`another bundle sync holds ${agent}'s lock${lock.holderPid === null ? "" : ` (pid ${lock.holderPid})`}, so nothing changed; try again in a moment`);
160
+ }
143
161
  try {
162
+ // A pause can begin while this move waited for the lock (the replay gate
163
+ // pauses, then takes the lock), so look again now that the lock is ours.
164
+ const pausedNow = (0, sync_1.syncPausedUntil)(agentRoot);
165
+ if (pausedNow !== null)
166
+ return pausedRefusal(pausedNow);
144
167
  if (run(["status", "--porcelain", "--", "agent.json"]).length > 0) {
145
168
  return refuse(`${agent}'s agent.json has uncommitted changes, so nothing changed; commit or discard them first (git -C ${agentRoot} diff agent.json)`);
146
169
  }
147
170
  const hasRemote = run(["remote"]).split("\n").includes(sync.remote);
171
+ const tracking = (0, sync_1.syncTrackingRef)(sync);
148
172
  let seen = null;
149
173
  let lastPushError = "";
150
174
  for (let attempt = 1; attempt <= MOVE_ATTEMPTS; attempt++) {
151
175
  if (hasRemote) {
152
- try {
153
- run(["fetch", "-q", sync.remote]);
154
- }
155
- catch (error) {
156
- return refuse(`could not reach ${agent}'s bundle remote, so nothing changed: ${errorText(error)}`);
176
+ const fetched = await network.fetch(agentRoot, sync);
177
+ if (!fetched.ok) {
178
+ return refuse(`could not reach ${agent}'s bundle remote, so nothing changed: ${fetched.error}`);
157
179
  }
158
- let unpushed;
159
180
  try {
160
- unpushed = Number(run(["rev-list", "--count", "@{u}..HEAD"]));
181
+ run(["rev-parse", "--verify", "-q", tracking]);
161
182
  }
162
- catch (error) {
163
- return refuse(`${agent}'s bundle branch has no upstream to publish the move to, so nothing changed: ${errorText(error)}`);
183
+ catch {
184
+ return refuse(`${agent}'s bundle remote has no ${sync.branch} branch to publish the move to, so nothing changed`);
164
185
  }
186
+ const unpushed = Number(run(["rev-list", "--count", `${tracking}..HEAD`]));
165
187
  if (unpushed > 0) {
166
188
  const recorded = recordedPushError(agentRoot);
167
- const why = recorded ? `the last bundle push failed with: ${recorded}` : `run \`git -C ${agentRoot} push\` to see why they did not publish`;
189
+ const why = recorded ? `the last bundle push failed with: ${recorded}` : `run \`ouro sync --agent ${agent}\` to see why they did not publish`;
168
190
  return refuse(`${agent}'s bundle has ${unpushed} unpushed commit${unpushed === 1 ? "" : "s"}, so nothing changed; fix bundle sync first, because a move must not publish them as a side effect (${why})`);
169
191
  }
170
192
  try {
171
- run(["merge", "--ff-only", "-q", "@{u}"]);
193
+ run(["merge", "--ff-only", "-q", tracking]);
172
194
  }
173
195
  catch (error) {
174
196
  return refuse(`could not bring ${agent}'s bundle up to date with its remote, so nothing changed: ${errorText(error)}`);
@@ -188,7 +210,7 @@ function moveAgentHomeHere(options) {
188
210
  }
189
211
  writeHome(config.parsed, current);
190
212
  try {
191
- run(["commit", "-q", "-m", `home: ${agent} moves to ${machineName}`, "--", "agent.json"]);
213
+ run(["-c", `user.name=${agent}`, "-c", `user.email=${agent}@ouro.local`, "commit", "-q", "-m", `home: ${agent} moves to ${machineName}`, "--", "agent.json"]);
192
214
  }
193
215
  catch (error) {
194
216
  fs.writeFileSync(configPath, config.text, "utf-8");
@@ -197,13 +219,10 @@ function moveAgentHomeHere(options) {
197
219
  if (!hasRemote) {
198
220
  return finish({ ok: true, changed: true, message: `${movedMessage(current)}; the bundle has no remote, so only this machine sees the change` });
199
221
  }
200
- try {
201
- run(["push", "-q", sync.remote]);
222
+ const pushed = await network.push(agentRoot, sync);
223
+ if (pushed.ok)
202
224
  return finish({ ok: true, changed: true, message: movedMessage(current) });
203
- }
204
- catch (error) {
205
- lastPushError = errorText(error);
206
- }
225
+ lastPushError = pushed.error ?? "push failed";
207
226
  // Undo exactly our own commit; the working tree goes back to the
208
227
  // pre-move agent.json. If even that fails, say what to run by hand.
209
228
  try {
@@ -220,6 +239,9 @@ function moveAgentHomeHere(options) {
220
239
  catch (error) {
221
240
  return refuse(`could not move ${agent}, so nothing changed: ${errorText(error)}`);
222
241
  }
242
+ finally {
243
+ lock.release();
244
+ }
223
245
  }
224
246
  function execGit(cwd, args) {
225
247
  return new Promise((resolve, reject) => {
@@ -232,18 +254,32 @@ function execGit(cwd, args) {
232
254
  });
233
255
  }
234
256
  /**
235
- * Fetch the bundle's remote (without touching the working tree) and read the
236
- * lease in the upstream agent.json, so a machine notices a move away from it
237
- * even while the agent is idle and never pulls.
257
+ * Fetch `<remote>/<sync.branch>` (without touching the working tree) and read
258
+ * the lease in the remote's agent.json, so a machine notices a move away from
259
+ * it even while the agent is idle and never pulls. Fetch only, under the sync
260
+ * lock without waiting: a busy lock or a sync pause skips this check (unknown).
261
+ * It never commits or pushes.
238
262
  */
239
263
  async function readUpstreamAgentHome(agentRoot, git = execGit) {
240
264
  try {
241
265
  const sync = readSyncSettings(JSON.parse(fs.readFileSync(path.join(agentRoot, "agent.json"), "utf-8")));
242
266
  if (!sync.enabled || !fs.existsSync(path.join(agentRoot, ".git")))
243
267
  return undefined;
244
- await git(agentRoot, ["fetch", "-q", sync.remote]);
245
- const upstream = JSON.parse(await git(agentRoot, ["show", "@{u}:agent.json"]));
246
- return (0, agent_discovery_1.parseAgentHome)(upstream.home);
268
+ if ((0, sync_1.syncPausedUntil)(agentRoot) !== null)
269
+ return undefined;
270
+ const lock = await (0, sync_1.holdSyncLock)(agentRoot, 0);
271
+ if ("busy" in lock)
272
+ return undefined;
273
+ try {
274
+ const fetched = await (0, sync_1.fetchRemote)(agentRoot, sync);
275
+ if (!fetched.ok)
276
+ return undefined;
277
+ const upstream = JSON.parse(await git(agentRoot, ["show", `${(0, sync_1.syncTrackingRef)(sync)}:agent.json`]));
278
+ return (0, agent_discovery_1.parseAgentHome)(upstream.home);
279
+ }
280
+ finally {
281
+ lock.release();
282
+ }
247
283
  }
248
284
  catch {
249
285
  return undefined;
@@ -2,10 +2,11 @@
2
2
  /**
3
3
  * Boot sync probe — Layer 2 orchestrator for `ouro up`.
4
4
  *
5
- * For each sync-enabled bundle, runs `preTurnPullAsync` wrapped in
6
- * `runWithTimeouts`, classifies failures via `classifySyncFailure`, and
7
- * returns aggregated findings. Findings surface in the boot stdout
8
- * summary written by `cli-exec.ts` after the probe phase.
5
+ * For each sync-enabled bundle, runs `probeSync` (fetch only, then count
6
+ * commits ahead and behind) wrapped in `runWithTimeouts`, classifies failures
7
+ * via `classifySyncFailure`, and returns aggregated findings plus one status
8
+ * per probed bundle. Both surface in the boot stdout summary written by
9
+ * `cli-exec.ts` after the probe phase.
9
10
  *
10
11
  * Layer 2 is intentionally a **boot-time preflight surface only** — it
11
12
  * does NOT feed into the running daemon's rollup state. `computeDaemonRollup`
@@ -15,6 +16,8 @@
15
16
  * dispatches the right diagnostic skill).
16
17
  *
17
18
  * The probe NEVER:
19
+ * - Merges, commits or pushes: it fetches only.
20
+ * - Waits for the bundle sync lock: a held lock is reported as busy.
18
21
  * - Writes to `state/` (verified by Unit 7's grep gate).
19
22
  * - Throws — every failure becomes a finding.
20
23
  * - Hangs — `AbortSignal` from `runWithTimeouts` cuts hung remotes
@@ -76,6 +79,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
76
79
  exports.runBootSyncProbe = runBootSyncProbe;
77
80
  const path = __importStar(require("path"));
78
81
  const sync_1 = require("../sync");
82
+ const sync_lock_1 = require("../sync-lock");
79
83
  const sync_classification_1 = require("../sync-classification");
80
84
  const timeouts_1 = require("../timeouts");
81
85
  const runtime_1 = require("../../nerves/runtime");
@@ -110,6 +114,7 @@ async function runBootSyncProbe(bundles, options) {
110
114
  meta: { bundleCount: bundles.length, softMs, hardMs },
111
115
  });
112
116
  const findings = [];
117
+ const statuses = [];
113
118
  for (const bundle of bundles) {
114
119
  if (!bundle.enabled)
115
120
  continue;
@@ -120,17 +125,24 @@ async function runBootSyncProbe(bundles, options) {
120
125
  findings.push({
121
126
  agent: bundle.agent,
122
127
  classification: "unknown",
123
- error: `bundle is not a git repo; run \`git init\` inside ${agentRoot} to enable sync (or disable sync in agent.json)`,
128
+ error: sync_1.NOT_A_REPO_ERROR,
124
129
  conflictFiles: [],
125
130
  warnings: [],
126
131
  advisory: true,
127
132
  });
128
133
  continue;
129
134
  }
130
- const syncConfig = { enabled: bundle.enabled, remote: bundle.remote };
131
- const outcome = await (0, timeouts_1.runWithTimeouts)((signal) => (0, sync_1.preTurnPullAsync)(agentRoot, syncConfig, { signal }), { softMs, hardMs, label: `boot-sync-probe ${bundle.agent}`, envKey: "GIT" });
135
+ const syncConfig = { enabled: bundle.enabled, remote: bundle.remote, branch: bundle.branch };
136
+ // A lock this pid left in an earlier life (PID 1 in the container) would read as busy forever.
137
+ try {
138
+ (0, sync_lock_1.clearOwnStaleSyncLock)(agentRoot);
139
+ }
140
+ catch {
141
+ // An unreadable lock is reported by the probe's own attempt below.
142
+ }
143
+ const outcome = await (0, timeouts_1.runWithTimeouts)((signal) => (0, sync_1.probeSync)(agentRoot, syncConfig, signal), { softMs, hardMs, label: `boot-sync-probe ${bundle.agent}`, envKey: "GIT" });
132
144
  // Hard timeout — the probe was aborted. Synthesise a finding from the
133
- // outcome's classification (the underlying `preTurnPullAsync` may also
145
+ // outcome's classification (the underlying `probeSync` may also
134
146
  // have rejected with an AbortError, but the wrapper already swallowed
135
147
  // it).
136
148
  if (outcome.classification === "timeout-hard") {
@@ -170,6 +182,9 @@ async function runBootSyncProbe(bundles, options) {
170
182
  });
171
183
  continue;
172
184
  }
185
+ const status = describeProbeResult(result, syncConfig);
186
+ if (status)
187
+ statuses.push({ agent: bundle.agent, text: status });
173
188
  // Probe succeeded. If the soft warning fired, surface it as an advisory
174
189
  // finding so the operator sees the slow-pull warning even on success.
175
190
  if (outcome.warnings.length > 0) {
@@ -186,12 +201,23 @@ async function runBootSyncProbe(bundles, options) {
186
201
  // Stable order — sort by agent name so renderers and tests see the same
187
202
  // sequence.
188
203
  findings.sort((a, b) => a.agent.localeCompare(b.agent));
204
+ statuses.sort((a, b) => a.agent.localeCompare(b.agent));
189
205
  const durationMs = Date.now() - startedAt;
190
206
  (0, runtime_1.emitNervesEvent)({
191
207
  component: "daemon",
192
208
  event: "daemon.boot_sync_probe_end",
193
209
  message: "boot sync probe complete",
194
- meta: { findingCount: findings.length, durationMs },
210
+ meta: { findingCount: findings.length, statusCount: statuses.length, durationMs },
195
211
  });
196
- return { findings, durationMs };
212
+ return { findings, statuses, durationMs };
213
+ }
214
+ /** The probe's view of one healthy bundle, or null when there is nothing to say (no remote). */
215
+ function describeProbeResult(result, config) {
216
+ if (result.paused)
217
+ return `sync paused until ${result.pausedUntil}`;
218
+ if (result.busy)
219
+ return `sync busy: another sync holds the lock${result.holderPid == null ? "" : ` (pid ${result.holderPid})`}`;
220
+ if (result.ahead === undefined || result.behind === undefined)
221
+ return null;
222
+ return `${result.ahead} ahead, ${result.behind} behind ${config.remote}/${config.branch}`;
197
223
  }