@ouro.bot/cli 0.1.0-alpha.416 → 0.1.0-alpha.418

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.418",
6
+ "changes": [
7
+ "`ouro up` now uses the live progress renderer in interactive terminals instead of forcing static non-TTY output, so long startup/check phases animate instead of leaving a blinking cursor.",
8
+ "Non-TTY and captured `ouro up` output now prints current phase starts and changed detail lines (`launching daemon process`, `waiting for daemon socket`, `daemon answered`) before completion, avoiding silent waits in logs and terminal sessions that cannot render spinners.",
9
+ "Daemon startup readiness is completed explicitly before provider checks begin; `ouro up` no longer marks `starting daemon` as done by accidentally auto-completing it when the next phase starts.",
10
+ "Daemon startup polling can now report progress into the parent `ouro up` checklist without rendering its own nested startup TUI, keeping startup output as one coherent surface.",
11
+ "Runtime drift restart messages now summarize drift categories such as `code path` or `managed agents` without printing raw worktree/package paths in normal CLI output.",
12
+ "Default CLI stdout now writes exactly one newline instead of using `console.log`, removing the extra blank lines caused when progress renderers already include newline-terminated output.",
13
+ "`@ouro.bot/cli` and the `ouro.bot` wrapper are version-synced for the `ouro up` progress polish release."
14
+ ]
15
+ },
16
+ {
17
+ "version": "0.1.0-alpha.417",
18
+ "changes": [
19
+ "`isTransientError` now recognizes timeout errors after `formatBwCliError` transforms them, so retries fire correctly even when the raw `ETIMEDOUT` code is lost during formatting.",
20
+ "New `withTransientRetry` wrapper on read operations (`get`, `getRawSecret`, `list`) retries up to 3 times with exponential backoff (500ms, 1s, 2s) and emits `repertoire.bw_transient_retry` nerves events, so transient vault hiccups resolve silently.",
21
+ "`bw sync` now runs after every login/unlock in `loginAttempt()`, ensuring vault data is synchronized with the server before any read operations.",
22
+ "Timeout error messages changed from a generic 'timed out while waiting for a vault response' to actionable guidance: 'timed out -- usually resolves on retry. If it persists, check network connectivity to the vault server.'",
23
+ "`@ouro.bot/cli` and the `ouro.bot` wrapper are version-synced for the Bitwarden store resilience release."
24
+ ]
25
+ },
4
26
  {
5
27
  "version": "0.1.0-alpha.416",
6
28
  "changes": [
@@ -89,9 +89,13 @@ function defaultStartDaemonProcess(socketPath) {
89
89
  return Promise.resolve({ pid: child.pid ?? null });
90
90
  }
91
91
  function defaultWriteStdout(text) {
92
- // eslint-disable-next-line no-console -- terminal UX: CLI command output
93
- console.log(text);
92
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
94
93
  }
94
+ /* v8 ignore start -- thin terminal adapter around process stdout @preserve */
95
+ function defaultWriteRaw(text) {
96
+ process.stdout.write(text);
97
+ }
98
+ /* v8 ignore stop */
95
99
  /**
96
100
  * Read the runtimeVersion from the first .ouro bundle's bundle-meta.json.
97
101
  * Returns undefined if none found or unreadable.
@@ -486,6 +490,8 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
486
490
  sendCommand: socket_client_1.sendDaemonCommand,
487
491
  startDaemonProcess: defaultStartDaemonProcess,
488
492
  writeStdout: defaultWriteStdout,
493
+ writeRaw: defaultWriteRaw,
494
+ isTTY: process.stdout.isTTY === true,
489
495
  checkSocketAlive: socket_client_1.checkDaemonSocketAlive,
490
496
  cleanupStaleSocket: defaultCleanupStaleSocket,
491
497
  fallbackPendingMessage: defaultFallbackPendingMessage,
@@ -179,6 +179,15 @@ function providerRepairCountSummary(count) {
179
179
  return "ok";
180
180
  return `${count} ${count === 1 ? "needs" : "need"} attention`;
181
181
  }
182
+ function daemonProgressSummary(result) {
183
+ if (result.verifyStartupStatus === false)
184
+ return "not answering yet";
185
+ if (result.alreadyRunning)
186
+ return "already running";
187
+ if (result.message.includes("restarted"))
188
+ return "restarted and ready";
189
+ return "ready";
190
+ }
182
191
  async function reportPostRepairProviderHealth(deps, repairedAgents, onProgress) {
183
192
  const remainingDegraded = await checkAgentProviders(deps, repairedAgents, onProgress);
184
193
  (0, runtime_1.emitNervesEvent)({
@@ -314,17 +323,19 @@ async function ensureDaemonRunning(deps, options = {}) {
314
323
  sendCommand: deps.sendCommand,
315
324
  socketPath: deps.socketPath,
316
325
  daemonPid: runtimeResult.startedPid ?? null,
317
- /* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
318
- writeRaw: (text) => process.stdout.write(text),
319
- /* v8 ignore next -- thin wrapper: real stdout TTY detection injected for captured-output safety @preserve */
320
- isTTY: process.stdout.isTTY === true,
321
- /* v8 ignore next -- thin wrapper: real Date.now() injected for testability @preserve */
322
- now: () => Date.now(),
323
- /* v8 ignore next -- thin wrapper: real setTimeout injected for testability @preserve */
324
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
326
+ /* v8 ignore next -- thin wrapper: real stdout fallback injected by default deps @preserve */
327
+ writeRaw: deps.writeRaw ?? ((text) => process.stdout.write(text)),
328
+ /* v8 ignore next -- thin wrapper: real stdout TTY detection injected by default deps @preserve */
329
+ isTTY: deps.isTTY ?? process.stdout.isTTY === true,
330
+ /* v8 ignore next -- thin wrapper: real Date.now fallback injected by default deps @preserve */
331
+ now: deps.now ?? (() => Date.now()),
332
+ /* v8 ignore next -- thin wrapper: real setTimeout fallback injected by default deps @preserve */
333
+ sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
325
334
  /* v8 ignore start -- daemon log tail + pid check: reads real filesystem, tested via deployment @preserve */
326
335
  readLatestDaemonEvent: readLatestDaemonStartupEvent,
327
336
  /* v8 ignore stop */
337
+ onProgress: deps.reportDaemonStartupPhase,
338
+ render: !deps.reportDaemonStartupPhase,
328
339
  });
329
340
  return {
330
341
  alreadyRunning: runtimeResult.alreadyRunning,
@@ -339,8 +350,8 @@ async function ensureDaemonRunning(deps, options = {}) {
339
350
  };
340
351
  let lastPid = null;
341
352
  for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
342
- deps.reportDaemonStartupPhase?.("starting daemon...");
343
- deps.reportDaemonStartupPhase?.("waiting for daemon socket...");
353
+ deps.reportDaemonStartupPhase?.("launching daemon process");
354
+ deps.reportDaemonStartupPhase?.("waiting for daemon socket");
344
355
  deps.cleanupStaleSocket(deps.socketPath);
345
356
  const bootStartedAtMs = (deps.now ?? Date.now)();
346
357
  const started = await deps.startDaemonProcess(deps.socketPath);
@@ -354,17 +365,19 @@ async function ensureDaemonRunning(deps, options = {}) {
354
365
  sendCommand: deps.sendCommand,
355
366
  socketPath: deps.socketPath,
356
367
  daemonPid: lastPid,
357
- /* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
358
- writeRaw: (text) => process.stdout.write(text),
359
- /* v8 ignore next -- thin wrapper: real stdout TTY detection injected for captured-output safety @preserve */
360
- isTTY: process.stdout.isTTY === true,
361
- /* v8 ignore next -- thin wrapper: real Date.now() injected for testability @preserve */
362
- now: () => Date.now(),
363
- /* v8 ignore next -- thin wrapper: real setTimeout injected for testability @preserve */
364
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
368
+ /* v8 ignore next -- thin wrapper: real stdout fallback injected by default deps @preserve */
369
+ writeRaw: deps.writeRaw ?? ((text) => process.stdout.write(text)),
370
+ /* v8 ignore next -- thin wrapper: real stdout TTY detection injected by default deps @preserve */
371
+ isTTY: deps.isTTY ?? process.stdout.isTTY === true,
372
+ /* v8 ignore next -- thin wrapper: real Date.now fallback injected by default deps @preserve */
373
+ now: deps.now ?? (() => Date.now()),
374
+ /* v8 ignore next -- thin wrapper: real setTimeout fallback injected by default deps @preserve */
375
+ sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
365
376
  /* v8 ignore start -- daemon log tail + pid check: reads real filesystem, tested via deployment @preserve */
366
377
  readLatestDaemonEvent: readLatestDaemonStartupEvent,
367
378
  /* v8 ignore stop */
379
+ onProgress: deps.reportDaemonStartupPhase,
380
+ render: !deps.reportDaemonStartupPhase,
368
381
  });
369
382
  return {
370
383
  alreadyRunning: false,
@@ -376,7 +389,7 @@ async function ensureDaemonRunning(deps, options = {}) {
376
389
  if (!startupFailure.retryable || attempt >= retryLimit) {
377
390
  break;
378
391
  }
379
- deps.reportDaemonStartupPhase?.("daemon startup lost stability; cleaning up and retrying once...");
392
+ deps.reportDaemonStartupPhase?.("daemon startup lost stability; retrying once");
380
393
  }
381
394
  return {
382
395
  alreadyRunning: false,
@@ -449,7 +462,7 @@ async function waitForDaemonStartup(deps, options) {
449
462
  if (!sawSocket) {
450
463
  sawSocket = true;
451
464
  stableSinceMs = now();
452
- deps.reportDaemonStartupPhase?.("verifying daemon health...");
465
+ deps.reportDaemonStartupPhase?.("verifying daemon health");
453
466
  }
454
467
  if (!hasFreshCurrentBootHealthSignal(deps, options.bootStartedAtMs, options.pid)) {
455
468
  continue;
@@ -2469,7 +2482,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
2469
2482
  }
2470
2483
  }
2471
2484
  const linkedVersionBeforeUp = deps.getCurrentCliVersion?.() ?? null;
2472
- const progress = new up_progress_1.UpProgress({ write: deps.writeStdout, isTTY: false });
2485
+ const outputIsTTY = deps.isTTY ?? process.stdout.isTTY === true;
2486
+ const progress = new up_progress_1.UpProgress({
2487
+ write: deps.writeRaw ?? deps.writeStdout,
2488
+ isTTY: outputIsTTY,
2489
+ now: deps.now ?? (() => Date.now()),
2490
+ autoRender: true,
2491
+ });
2473
2492
  // ── versioned CLI update check ──
2474
2493
  if (deps.checkForCliUpdate) {
2475
2494
  progress.startPhase("update check");
@@ -2631,6 +2650,12 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
2631
2650
  progress.announceStep?.(label);
2632
2651
  },
2633
2652
  }, { initialAlive: daemonAliveBeforeStart });
2653
+ progress.completePhase("starting daemon", daemonProgressSummary(daemonResult));
2654
+ if (daemonResult.verifyStartupStatus === false) {
2655
+ progress.end();
2656
+ deps.writeStdout(daemonResult.message);
2657
+ return daemonResult.message;
2658
+ }
2634
2659
  if (!providerChecksAlreadyRun || daemonResult.alreadyRunning) {
2635
2660
  progress.startPhase("provider checks");
2636
2661
  const providerDegraded = await checkAlreadyRunningAgentProviders(deps, (msg) => progress.updateDetail(msg));
@@ -2638,7 +2663,6 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
2638
2663
  progress.completePhase("provider checks", providerRepairCountSummary(providerDegraded.length));
2639
2664
  }
2640
2665
  progress.end();
2641
- deps.writeStdout(daemonResult.message);
2642
2666
  // Interactive repair for degraded agents (Unit 5) — skipped by --no-repair (Unit 6)
2643
2667
  if (daemonResult.stability?.degraded && daemonResult.stability.degraded.length > 0) {
2644
2668
  if (command.noRepair) {
@@ -83,14 +83,8 @@ function collectRuntimeDriftReasons(local, running) {
83
83
  }
84
84
  return reasons;
85
85
  }
86
- function formatRuntimeValue(reason) {
87
- if (reason.key === "configFingerprint") {
88
- return `${reason.running.slice(0, 12)} -> ${reason.local.slice(0, 12)}`;
89
- }
90
- return `${reason.running} -> ${reason.local}`;
91
- }
92
- function formatRuntimeDriftSummary(reasons) {
93
- return reasons.map((reason) => `${reason.label} ${formatRuntimeValue(reason)}`).join("; ");
86
+ function formatRuntimeDriftPublicSummary(reasons) {
87
+ return reasons.map((reason) => reason.label).join(", ");
94
88
  }
95
89
  async function ensureCurrentDaemonRuntime(deps) {
96
90
  const localRuntime = normalizeRuntimeIdentity({
@@ -107,7 +101,7 @@ async function ensureCurrentDaemonRuntime(deps) {
107
101
  let result;
108
102
  if (driftReasons.length > 0) {
109
103
  const includesVersionDrift = driftReasons.some((entry) => entry.key === "version");
110
- const driftSummary = formatRuntimeDriftSummary(driftReasons);
104
+ const publicDriftSummary = formatRuntimeDriftPublicSummary(driftReasons);
111
105
  try {
112
106
  await deps.stopDaemon();
113
107
  }
@@ -117,7 +111,7 @@ async function ensureCurrentDaemonRuntime(deps) {
117
111
  alreadyRunning: true,
118
112
  message: includesVersionDrift
119
113
  ? `daemon already running (${deps.socketPath}; could not replace stale daemon ${runningVersion} -> ${deps.localVersion}: ${reason})`
120
- : `daemon already running (${deps.socketPath}; could not replace drifted daemon ${driftSummary}: ${reason})`,
114
+ : `daemon already running (${deps.socketPath}; could not replace runtime drift ${publicDriftSummary}: ${reason})`,
121
115
  };
122
116
  (0, runtime_1.emitNervesEvent)({
123
117
  level: "warn",
@@ -148,12 +142,12 @@ async function ensureCurrentDaemonRuntime(deps) {
148
142
  const pid = started.pid ?? "unknown";
149
143
  const verified = await verifyDaemonStarted(deps);
150
144
  /* v8 ignore next -- daemon liveness failure: requires real daemon crash timing @preserve */
151
- const suffix = verified ? "" : "\ndaemon did not answer yet, so Ouro is checking repair paths next.";
145
+ const suffix = verified ? "" : "\ndaemon restart has not answered yet; check logs with `ouro logs` or run `ouro doctor`.";
152
146
  result = {
153
147
  alreadyRunning: false,
154
148
  message: includesVersionDrift
155
149
  ? `restarted stale daemon ${runningVersion} -> ${deps.localVersion} (pid ${pid})${suffix}`
156
- : `restarted drifted daemon (${driftSummary}) (pid ${pid})${suffix}`,
150
+ : `restarted daemon after runtime drift: ${publicDriftSummary} (pid ${pid})${suffix}`,
157
151
  verifyStartupStatus: verified,
158
152
  startedPid: started.pid ?? null,
159
153
  };
@@ -134,6 +134,14 @@ async function pollDaemonStartup(deps) {
134
134
  let prevLineCount = 0;
135
135
  const isTTY = deps.isTTY ?? true;
136
136
  const isAlive = deps.isProcessAlive ?? defaultIsProcessAlive;
137
+ const shouldRender = deps.render ?? true;
138
+ let lastProgress = null;
139
+ const reportProgress = (message) => {
140
+ if (!deps.onProgress || message === lastProgress)
141
+ return;
142
+ lastProgress = message;
143
+ deps.onProgress(message);
144
+ };
137
145
  (0, runtime_1.emitNervesEvent)({
138
146
  component: "daemon",
139
147
  event: "daemon.startup_poll_start",
@@ -174,22 +182,30 @@ async function pollDaemonStartup(deps) {
174
182
  }
175
183
  // Show what the daemon is doing from its log
176
184
  const latestEvent = deps.readLatestDaemonEvent?.() ?? null;
177
- const output = renderWaitingForDaemon(elapsed, latestEvent, prevLineCount, { isTTY });
178
- deps.writeRaw(output);
179
- prevLineCount = latestEvent ? 2 : 1;
185
+ reportProgress(latestEvent ?? "waiting for daemon");
186
+ if (shouldRender) {
187
+ const output = renderWaitingForDaemon(elapsed, latestEvent, prevLineCount, { isTTY });
188
+ deps.writeRaw(output);
189
+ prevLineCount = latestEvent ? 2 : 1;
190
+ }
180
191
  }
181
192
  if (payload) {
182
- const output = renderStartupProgress(payload, elapsed, prevLineCount, { isTTY });
183
- deps.writeRaw(output);
184
- prevLineCount = payload.workers.length + 1;
193
+ reportProgress(formatStartupProgressDetail(payload));
194
+ if (shouldRender) {
195
+ const output = renderStartupProgress(payload, elapsed, prevLineCount, { isTTY });
196
+ deps.writeRaw(output);
197
+ prevLineCount = payload.workers.length + 1;
198
+ }
185
199
  const assessment = assessStability(payload, now);
186
200
  if (assessment.resolved) {
187
201
  const result = {
188
202
  stable: assessment.stable,
189
203
  degraded: assessment.degraded,
190
204
  };
191
- const summary = renderFinalSummary(result, isTTY);
192
- deps.writeRaw(summary);
205
+ if (shouldRender) {
206
+ const summary = renderFinalSummary(result, isTTY);
207
+ deps.writeRaw(summary);
208
+ }
193
209
  (0, runtime_1.emitNervesEvent)({
194
210
  component: "daemon",
195
211
  event: "daemon.startup_poll_end",
@@ -206,6 +222,12 @@ async function pollDaemonStartup(deps) {
206
222
  await deps.sleep(POLL_INTERVAL_MS);
207
223
  }
208
224
  }
225
+ function formatStartupProgressDetail(payload) {
226
+ if (payload.workers.length === 0)
227
+ return "daemon answered";
228
+ const workers = payload.workers.map((worker) => `${worker.agent}/${worker.worker} ${worker.status}`).join(", ");
229
+ return `waiting for agents: ${workers}`;
230
+ }
209
231
  function colorStatus(status) {
210
232
  const statusColor = status === "running" ? GREEN
211
233
  : status === "crashed" ? RED
@@ -7,8 +7,9 @@
7
7
  * cursor control for in-place overwriting in TTY mode, and falls back to
8
8
  * static line-per-phase output in non-TTY mode.
9
9
  *
10
- * The caller drives animation by calling `render(now)` on a setInterval.
11
- * This module owns no timers.
10
+ * The caller can drive animation by calling `render(now)`. In production CLI
11
+ * use, `autoRender` starts a short-lived timer while a TTY phase is active so
12
+ * long operations never leave a dead-looking cursor.
12
13
  */
13
14
  Object.defineProperty(exports, "__esModule", { value: true });
14
15
  exports.UpProgress = void 0;
@@ -23,15 +24,30 @@ const GREEN = "\x1b[38;2;46;204;64m";
23
24
  class UpProgress {
24
25
  write;
25
26
  isTTY;
27
+ now;
28
+ autoRender;
29
+ renderIntervalMs;
30
+ setTimer;
31
+ clearTimer;
26
32
  completed = [];
27
33
  currentPhase = null;
34
+ currentDetail = null;
28
35
  prevLineCount = 0;
29
36
  ended = false;
37
+ renderTimer = null;
30
38
  constructor(options) {
31
39
  /* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
32
40
  this.write = options?.write ?? ((text) => process.stdout.write(text));
33
41
  /* v8 ignore next -- thin wrapper: real isTTY check injected for testability @preserve */
34
42
  this.isTTY = options?.isTTY ?? (process.stdout.isTTY === true);
43
+ /* v8 ignore next -- thin wrapper: real Date.now injected for testability @preserve */
44
+ this.now = options?.now ?? (() => Date.now());
45
+ this.autoRender = options?.autoRender ?? false;
46
+ this.renderIntervalMs = options?.renderIntervalMs ?? 80;
47
+ /* v8 ignore start -- real timers are injected in tests when needed @preserve */
48
+ this.setTimer = options?.setInterval ?? ((callback, ms) => setInterval(callback, ms));
49
+ this.clearTimer = options?.clearInterval ?? ((handle) => clearInterval(handle));
50
+ /* v8 ignore stop */
35
51
  }
36
52
  /**
37
53
  * Begin a new phase with spinner. If a phase is already active, it is
@@ -41,26 +57,44 @@ class UpProgress {
41
57
  if (this.currentPhase) {
42
58
  this.completePhase(this.currentPhase.label);
43
59
  }
44
- this.currentPhase = { label, startedAt: Date.now() };
60
+ this.currentPhase = { label, startedAt: this.now() };
61
+ this.currentDetail = null;
62
+ if (this.isTTY) {
63
+ this.ensureAutoRender();
64
+ this.flushRender();
65
+ }
66
+ else {
67
+ this.write(` ... ${label}\n`);
68
+ }
45
69
  }
46
70
  /**
47
71
  * Emit a one-line status breadcrumb in non-TTY mode without affecting the
48
72
  * accumulated checklist state. Used for daemon startup sub-steps.
49
73
  */
50
74
  announceStep(label) {
75
+ if (this.currentPhase) {
76
+ this.updateDetail(label);
77
+ return;
78
+ }
51
79
  if (this.isTTY)
52
80
  return;
53
- this.write(label);
81
+ this.write(` ${label}\n`);
54
82
  }
55
83
  /**
56
84
  * Update the sub-step detail on the current spinner phase. Rendered as
57
- * "label (Xs) -- detail" in TTY mode. No-op in non-TTY mode or when
58
- * no phase is active.
85
+ * "label (Xs) -- detail" in TTY mode. In non-TTY mode, writes changed
86
+ * detail lines so long operations remain visible in logs and captured output.
59
87
  */
60
88
  updateDetail(detail) {
61
- if (!this.isTTY || !this.currentPhase)
89
+ if (!this.currentPhase || detail === this.currentDetail)
62
90
  return;
91
+ this.currentDetail = detail;
63
92
  this.currentPhase.detail = detail;
93
+ if (this.isTTY) {
94
+ this.flushRender();
95
+ return;
96
+ }
97
+ this.write(` ${detail}\n`);
64
98
  }
65
99
  /**
66
100
  * Mark the current phase as done. In non-TTY mode, immediately writes
@@ -70,16 +104,21 @@ class UpProgress {
70
104
  if (!this.currentPhase) {
71
105
  return;
72
106
  }
73
- const elapsedMs = Date.now() - this.currentPhase.startedAt;
107
+ const elapsedMs = this.now() - this.currentPhase.startedAt;
74
108
  this.completed.push({ label, detail });
75
109
  this.currentPhase = null;
110
+ this.currentDetail = null;
111
+ this.stopAutoRender();
76
112
  (0, runtime_1.emitNervesEvent)({
77
113
  component: "daemon",
78
114
  event: "daemon.up_phase_complete",
79
115
  message: `phase complete: ${label}`,
80
116
  meta: { phase: label, detail: detail ?? null, elapsedMs },
81
117
  });
82
- if (!this.isTTY) {
118
+ if (this.isTTY) {
119
+ this.flushRender();
120
+ }
121
+ else {
83
122
  const detailStr = detail ? ` \u2014 ${detail}` : "";
84
123
  this.write(` \u2713 ${label}${detailStr}\n`);
85
124
  }
@@ -134,12 +173,30 @@ class UpProgress {
134
173
  this.ended = true;
135
174
  if (this.currentPhase) {
136
175
  this.currentPhase = null;
176
+ this.currentDetail = null;
137
177
  }
178
+ this.stopAutoRender();
138
179
  if (this.isTTY) {
139
- const output = this.render(Date.now());
140
- if (output) {
141
- this.write(output);
142
- }
180
+ this.flushRender();
181
+ }
182
+ }
183
+ ensureAutoRender() {
184
+ if (!this.autoRender || !this.isTTY || this.renderTimer !== null) {
185
+ return;
186
+ }
187
+ this.renderTimer = this.setTimer(() => this.flushRender(), this.renderIntervalMs);
188
+ }
189
+ stopAutoRender() {
190
+ if (this.renderTimer === null) {
191
+ return;
192
+ }
193
+ this.clearTimer(this.renderTimer);
194
+ this.renderTimer = null;
195
+ }
196
+ flushRender() {
197
+ const output = this.render(this.now());
198
+ if (output) {
199
+ this.write(output);
143
200
  }
144
201
  }
145
202
  }
@@ -116,7 +116,7 @@ function sanitizeBwErrorDetail(message) {
116
116
  function formatBwCliError(err, stderr = "", args = []) {
117
117
  const operation = formatBwOperation(args);
118
118
  if (isBwTimeoutError(err)) {
119
- return new Error(`bw CLI error: ${operation} timed out while waiting for a vault response`);
119
+ return new Error(`bw CLI error: ${operation} timed out -- usually resolves on retry. If it persists, check network connectivity to the vault server.`);
120
120
  }
121
121
  const detail = sanitizeBwErrorDetail(stderr.trim() || err.message);
122
122
  if (detail === "command failed") {
@@ -274,10 +274,13 @@ function isTransientError(err) {
274
274
  msg.includes("enotfound") ||
275
275
  msg.includes("socket hang up") ||
276
276
  msg.includes("503") ||
277
- msg.includes("server unavailable"));
277
+ msg.includes("server unavailable") ||
278
+ msg.includes("timed out"));
278
279
  }
279
280
  const MAX_RETRIES = 3;
280
281
  const BASE_BACKOFF_MS = 1000;
282
+ const TRANSIENT_MAX_RETRIES = 3;
283
+ const TRANSIENT_RETRY_BASE_MS = 500;
281
284
  function delay(ms) {
282
285
  return new Promise((resolve) => setTimeout(resolve, ms));
283
286
  }
@@ -469,6 +472,9 @@ class BitwardenCredentialStore {
469
472
  const unlockOutput = await execBw(["unlock", this.masterPassword, "--raw"], undefined, this.appDataDir);
470
473
  this.sessionToken = unlockOutput.trim();
471
474
  }
475
+ // Sync vault data after obtaining a fresh session token
476
+ /* v8 ignore next -- defensive: loginAttempt always sets sessionToken before sync @preserve */
477
+ await execBw(["sync"], this.sessionToken ?? undefined, this.appDataDir);
472
478
  }
473
479
  async ensureSession() {
474
480
  if (!this.sessionToken) {
@@ -494,6 +500,32 @@ class BitwardenCredentialStore {
494
500
  }
495
501
  }
496
502
  }
503
+ async withTransientRetry(operation) {
504
+ let lastError;
505
+ for (let attempt = 0; attempt < TRANSIENT_MAX_RETRIES; attempt++) {
506
+ try {
507
+ return await operation();
508
+ }
509
+ catch (err) {
510
+ /* v8 ignore next -- defensive: operation always throws Error instances @preserve */
511
+ lastError = err instanceof Error ? err : new Error(String(err));
512
+ if (!isTransientError(lastError)) {
513
+ throw lastError;
514
+ }
515
+ if (attempt === TRANSIENT_MAX_RETRIES - 1)
516
+ break;
517
+ const backoffMs = TRANSIENT_RETRY_BASE_MS * Math.pow(2, attempt);
518
+ (0, runtime_1.emitNervesEvent)({
519
+ event: "repertoire.bw_transient_retry",
520
+ component: "repertoire",
521
+ message: `transient bw error, retrying in ${backoffMs}ms`,
522
+ meta: { attempt: attempt + 1, backoffMs, reason: lastError.message },
523
+ });
524
+ await delay(backoffMs);
525
+ }
526
+ }
527
+ throw lastError;
528
+ }
497
529
  async get(domain) {
498
530
  (0, runtime_1.emitNervesEvent)({
499
531
  event: "repertoire.bw_credential_get_start",
@@ -501,7 +533,7 @@ class BitwardenCredentialStore {
501
533
  message: `getting credential via bw for ${domain}`,
502
534
  meta: { domain, backend: "bitwarden" },
503
535
  });
504
- const item = await this.withSessionRetry((session) => this.findItemByDomain(domain, session));
536
+ const item = await this.withTransientRetry(() => this.withSessionRetry((session) => this.findItemByDomain(domain, session)));
505
537
  if (!item) {
506
538
  (0, runtime_1.emitNervesEvent)({
507
539
  event: "repertoire.bw_credential_get_end",
@@ -525,7 +557,7 @@ class BitwardenCredentialStore {
525
557
  };
526
558
  }
527
559
  async getRawSecret(domain, field) {
528
- const item = await this.withSessionRetry((session) => this.findItemByDomain(domain, session));
560
+ const item = await this.withTransientRetry(() => this.withSessionRetry((session) => this.findItemByDomain(domain, session)));
529
561
  if (!item) {
530
562
  throw new Error(`no credential found for domain "${domain}"`);
531
563
  }
@@ -595,7 +627,7 @@ class BitwardenCredentialStore {
595
627
  message: "listing bw credentials",
596
628
  meta: { backend: "bitwarden" },
597
629
  });
598
- const stdout = await this.withSessionRetry((session) => execBw(["list", "items"], session, this.appDataDir));
630
+ const stdout = await this.withTransientRetry(() => this.withSessionRetry((session) => execBw(["list", "items"], session, this.appDataDir)));
599
631
  const items = parseBwItems(stdout, "bw list items");
600
632
  const results = items.map((item) => ({
601
633
  domain: item.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.416",
3
+ "version": "0.1.0-alpha.418",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",