@bli-cockpit/cli 0.2.237 → 0.2.239

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 (148) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +15 -11
  2. package/dist/adapters/attribution-core-score.js +111 -87
  3. package/dist/adapters/car-state.js +38 -35
  4. package/dist/adapters/claude-attribution-discovery.js +81 -56
  5. package/dist/adapters/claude-attribution-score.js +108 -94
  6. package/dist/adapters/claude-attribution-signals.js +52 -42
  7. package/dist/adapters/codex-attribution.js +42 -33
  8. package/dist/adapters/codex-session-signals.js +11 -9
  9. package/dist/adapters/git-state.js +56 -50
  10. package/dist/adapters/local-sources.js +52 -43
  11. package/dist/adapters/raw-evidence-git-diff.js +4 -2
  12. package/dist/adapters/raw-evidence-sanitize.js +15 -12
  13. package/dist/adapters/raw-evidence-transcript-file.js +32 -31
  14. package/dist/autostart-self-heal.js +18 -2
  15. package/dist/commands/agent-session-report.js +51 -59
  16. package/dist/commands/agent-setup-observed.js +30 -23
  17. package/dist/commands/agent-setup.js +96 -45
  18. package/dist/commands/autostart-heal.js +8 -2
  19. package/dist/commands/autostart-reading.js +3 -0
  20. package/dist/commands/backfill-evidence-outcomes.js +9 -6
  21. package/dist/commands/backfill-issues.js +3 -0
  22. package/dist/commands/backfill-scan.js +27 -22
  23. package/dist/commands/backfill-upload.js +69 -60
  24. package/dist/commands/backfill.js +26 -21
  25. package/dist/commands/brief-done.js +4 -1
  26. package/dist/commands/brief-edit.js +71 -46
  27. package/dist/commands/cal-detail.js +35 -0
  28. package/dist/commands/cal-direct.js +119 -0
  29. package/dist/commands/cal-overlay.js +73 -30
  30. package/dist/commands/cal.js +62 -25
  31. package/dist/commands/clean.js +4 -1
  32. package/dist/commands/docs-read.js +5 -2
  33. package/dist/commands/docs.js +38 -6
  34. package/dist/commands/doctor-backfill.js +88 -71
  35. package/dist/commands/doctor-pipeline.js +59 -46
  36. package/dist/commands/heartbeat-post.js +116 -0
  37. package/dist/commands/heartbeat-token-lock.js +46 -33
  38. package/dist/commands/heartbeat.js +43 -125
  39. package/dist/commands/install-update.js +17 -15
  40. package/dist/commands/issue-show.js +27 -28
  41. package/dist/commands/issue-versions.js +93 -0
  42. package/dist/commands/issue-write.js +70 -23
  43. package/dist/commands/issue.js +73 -37
  44. package/dist/commands/jarvis-live-answer.js +99 -0
  45. package/dist/commands/jarvis-render.js +1 -94
  46. package/dist/commands/jarvis-trace.js +30 -22
  47. package/dist/commands/jarvis-turn-report.js +124 -0
  48. package/dist/commands/jarvis-turn.js +108 -182
  49. package/dist/commands/jarvis.js +26 -17
  50. package/dist/commands/local-args-collector-setup.js +82 -84
  51. package/dist/commands/local-args-collector-work.js +37 -38
  52. package/dist/commands/local-args-tower-admin.js +155 -142
  53. package/dist/commands/local-args-tower-cal.js +69 -54
  54. package/dist/commands/local-args-tower-careers.js +73 -54
  55. package/dist/commands/local-args-tower-docs-msg.js +79 -51
  56. package/dist/commands/local-args-tower-github.js +26 -20
  57. package/dist/commands/local-args-tower-hits.js +32 -20
  58. package/dist/commands/local-args-tower-manager.js +85 -4
  59. package/dist/commands/local-args-tower-memory.js +139 -117
  60. package/dist/commands/local-args-tower-msg.js +24 -6
  61. package/dist/commands/local-args-tower-notes.js +346 -0
  62. package/dist/commands/local-args-tower-pages.js +100 -378
  63. package/dist/commands/local-args-tower-settings.js +28 -29
  64. package/dist/commands/local-args-tower-setup.js +11 -4
  65. package/dist/commands/local-args-tower-work.js +74 -37
  66. package/dist/commands/local-args.js +80 -129
  67. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  68. package/dist/commands/local-help-commands-tower.js +20 -7
  69. package/dist/commands/local-help-commands.js +342 -341
  70. package/dist/commands/local-help.js +87 -83
  71. package/dist/commands/local-run-guards.js +100 -0
  72. package/dist/commands/local.js +137 -206
  73. package/dist/commands/login.js +13 -9
  74. package/dist/commands/logs.js +55 -38
  75. package/dist/commands/mail-attachments.js +44 -26
  76. package/dist/commands/manager-render-home.js +208 -0
  77. package/dist/commands/manager-render.js +7 -3
  78. package/dist/commands/manager-requests.js +33 -0
  79. package/dist/commands/manager.js +39 -5
  80. package/dist/commands/memory-consolidation.js +18 -9
  81. package/dist/commands/memory-daemon-probe.js +37 -28
  82. package/dist/commands/memory-install-config.js +64 -60
  83. package/dist/commands/memory-install.js +21 -41
  84. package/dist/commands/memory-requeue.js +26 -19
  85. package/dist/commands/memory-rules.js +26 -18
  86. package/dist/commands/memory-search-print.js +26 -0
  87. package/dist/commands/memory-store.js +14 -30
  88. package/dist/commands/memory-verb-list.js +49 -48
  89. package/dist/commands/msg-read-line.js +32 -0
  90. package/dist/commands/msg.js +45 -3
  91. package/dist/commands/notes-accounts.js +49 -32
  92. package/dist/commands/notes-sync-loop.js +115 -89
  93. package/dist/commands/notes-wisprflow-login.js +44 -23
  94. package/dist/commands/notes-writes.js +115 -100
  95. package/dist/commands/notes.js +33 -55
  96. package/dist/commands/onboard-flows.js +69 -51
  97. package/dist/commands/onboard.js +11 -9
  98. package/dist/commands/ops-render-memory.js +10 -7
  99. package/dist/commands/ops-render.js +33 -30
  100. package/dist/commands/ops.js +3 -0
  101. package/dist/commands/public-root.js +1 -1
  102. package/dist/commands/session-sync-outside-roots.js +40 -36
  103. package/dist/commands/session-sync-plan.js +14 -10
  104. package/dist/commands/session-sync-upload.js +24 -22
  105. package/dist/commands/session-sync.js +19 -8
  106. package/dist/commands/setup.js +13 -0
  107. package/dist/commands/slack.js +25 -16
  108. package/dist/commands/sources-check.js +13 -7
  109. package/dist/commands/sources-discovery.js +25 -17
  110. package/dist/commands/sources-verdict.js +19 -18
  111. package/dist/commands/speed.js +23 -19
  112. package/dist/commands/start.js +20 -16
  113. package/dist/commands/sync-doctor.js +52 -48
  114. package/dist/commands/sync.js +22 -16
  115. package/dist/commands/usage-telemetry-toml.js +71 -45
  116. package/dist/commands/usage.js +94 -68
  117. package/dist/commands/workbook-render.js +10 -7
  118. package/dist/evidence-reconcile-client.js +75 -64
  119. package/dist/evidence-redelivery-plan.js +37 -34
  120. package/dist/evidence-redelivery.js +32 -29
  121. package/dist/evidence-transcripts-client.js +20 -20
  122. package/dist/evidence-upload-object.js +86 -89
  123. package/dist/evidence-upload-plan.js +94 -83
  124. package/dist/evidence-upload-terminal.js +45 -50
  125. package/dist/image-description-session.js +156 -159
  126. package/dist/local-state-pairing-code.js +39 -31
  127. package/dist/local-state-status.js +9 -2
  128. package/dist/onboarding-roots.js +5 -8
  129. package/dist/process-runner.js +8 -8
  130. package/dist/raw-evidence-gc.js +33 -38
  131. package/dist/raw-evidence-stuck-summary.js +7 -14
  132. package/dist/repo-identity.js +26 -23
  133. package/dist/scheduled-self-update.js +11 -15
  134. package/dist/second-install.js +3 -0
  135. package/dist/slim/codex-rules.js +23 -36
  136. package/dist/spool/local-spool-parse.js +13 -12
  137. package/dist/sync-lock.js +5 -15
  138. package/dist/tower-client.js +36 -53
  139. package/dist/tower-stream.js +7 -24
  140. package/dist/upload-agent-artifacts.js +58 -64
  141. package/dist/upload-envelope-build.js +60 -59
  142. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  143. package/dist/upload-evidence-delivery-summary.js +32 -34
  144. package/dist/upload-ingest-receipt.js +20 -23
  145. package/dist/upload-session-reports-queue.js +4 -1
  146. package/dist/upload-session-reports-wire.js +64 -68
  147. package/dist/upload-sync.js +171 -157
  148. package/package.json +4 -4
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `cockpit msg` — channels and messages, typed (BLI-3706, BLI-3749).
3
3
  *
4
- * Six verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
4
+ * Eight verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
5
5
  * collector device token through `resolveCaller({ allowDeviceToken: true })`
6
6
  * on every route — no server-side door change was needed for this terminal.
7
7
  *
@@ -18,6 +18,7 @@
18
18
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
20
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
21
+ import { markReadThrough } from "./msg-read-line.js";
21
22
  const TAG = "[msg cli]";
22
23
  const READ_DEADLINE_MS = 30_000;
23
24
  const WRITE_DEADLINE_MS = 30_000;
@@ -31,6 +32,8 @@ export async function runMsg(command, io) {
31
32
  return listChannels(door);
32
33
  case "read":
33
34
  return readChannel(command, door);
35
+ case "unread":
36
+ return markUnread(command, door);
34
37
  case "send":
35
38
  return sendMessage(command, door);
36
39
  case "thread":
@@ -151,11 +154,20 @@ async function readChannel(command, door) {
151
154
  // BLI-10449: whether this channel is Tower's own or a Slack snapshot, and
152
155
  // how fresh — the door's own sentence, printed as sent.
153
156
  const provenance = body.provenance ?? null;
157
+ const readBoundary = body.readBoundary ?? null;
154
158
  const limitApplied = body.limitApplied ?? null;
155
- if (door.json)
156
- return emitAgentDoor(door, { ok: true, ...body, channelId: resolved.id, provenance, limitApplied, messages, nextCursor: body.nextCursor, hasMore: body.hasMore, coverage: body.coverage });
159
+ // `--mark-read` (BLI-21299): the person has just been shown these messages, so
160
+ // the read line moves to the newest top-level one - the only way a terminal
161
+ // moves it. Never implied by reading alone.
162
+ const readMove = command.markRead ? await markReadThrough(door, resolved.id, messages) : null;
163
+ const exitCode = readMove && !readMove.ok ? 1 : 0;
164
+ if (door.json) {
165
+ return emitAgentDoor(door, { ok: true, ...body, channelId: resolved.id, provenance, readBoundary, limitApplied, messages, nextCursor: body.nextCursor, hasMore: body.hasMore, coverage: body.coverage, ...(readMove ? { readMove } : {}) }, exitCode);
166
+ }
157
167
  if (provenance?.sentence)
158
168
  writeLine(door.io.stdout, provenance.sentence);
169
+ if (readBoundary?.sentence)
170
+ writeLine(door.io.stdout, readBoundary.sentence);
159
171
  if (limitApplied !== null) {
160
172
  writeLine(door.io.stdout, `Showing the newest ${limitApplied}: one read returns at most ${limitApplied} messages.`);
161
173
  }
@@ -170,6 +182,36 @@ async function readChannel(command, door) {
170
182
  const who = message.agent_label ?? message.user_id ?? message.slack_author_label ?? "someone";
171
183
  writeLine(door.io.stdout, `${message.id} ${message.created_at} ${who} ${message.content ?? ""}`);
172
184
  }
185
+ if (readMove) {
186
+ writeLine(readMove.ok ? door.io.stdout : door.io.stderr, readMove.ok
187
+ ? `Read line moved to ${readMove.messageId} (${readMove.changed ? "advanced" : "already at or past it"}).`
188
+ : `Read line NOT moved (${readMove.reason}): ${readMove.detail} Run the same command again to retry.`);
189
+ }
190
+ return exitCode;
191
+ }
192
+ /** `cockpit msg unread <channel> --from <message-id>` — this message and everything after it counts as unread. */
193
+ async function markUnread(command, door) {
194
+ const ref = command.channelRef ?? "";
195
+ const messageId = command.unreadFrom ?? "";
196
+ const resolved = await resolveChannelId(door, ref);
197
+ if (resolved.status === "list_failed")
198
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
199
+ if (resolved.status === "not_found")
200
+ return channelNotFound(door, ref);
201
+ const answer = await askAgentDoor(door, {
202
+ path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/read`,
203
+ method: "POST",
204
+ label: "msg unread",
205
+ timeoutMs: WRITE_DEADLINE_MS,
206
+ body: { message_id: messageId, action: "unread" },
207
+ });
208
+ if (!answer.ok)
209
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
210
+ const move = answer.body.move ?? {};
211
+ writeLine(door.io.stderr, `${TAG} unread ${JSON.stringify({ channel_id: resolved.id, message_id: messageId })}`);
212
+ if (door.json)
213
+ return emitAgentDoor(door, { ok: true, channelId: resolved.id, messageId, move });
214
+ writeLine(door.io.stdout, `Marked ${messageId} and everything after it unread in ${ref}. Read line now at ${move.lastReadAt ?? "the start"}.`);
173
215
  return 0;
174
216
  }
175
217
  async function readThread(command, door) {
@@ -80,6 +80,25 @@ function writeSharedFeeds(door, feeds) {
80
80
  }
81
81
  writeLine(door.io.stdout, " Its runs in full: `cockpit notes granola-status`.");
82
82
  }
83
+ function writeHowToConnect(door) {
84
+ writeLine(door.io.stdout, "");
85
+ writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
86
+ writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
87
+ writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (opens Wispr's sign-in in your browser and waits; no key)");
88
+ }
89
+ function writeAccountRows(door, accounts) {
90
+ for (const account of accounts) {
91
+ const health = account.status === "active"
92
+ ? "active"
93
+ : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
94
+ // The workspace rides beside the provider because a Fellow account that
95
+ // points at the wrong subdomain fails with a 401 and nothing else says so.
96
+ const where = account.workspace ? `${account.provider}/${account.workspace}` : account.provider;
97
+ writeLine(door.io.stdout, `${(account.label ?? account.provider).padEnd(20)} ${where.padEnd(20)} ${health.padEnd(24)} ${account.last_synced_at ?? "never read"}`);
98
+ }
99
+ writeLine(door.io.stdout, "");
100
+ writeLine(door.io.stdout, `${accounts.length} notetaker(s). ids: ${accounts.map((account) => account.id).join(", ")}`);
101
+ }
83
102
  export async function listNotesAccounts(command, door) {
84
103
  const answer = await ask(door, {
85
104
  path: "/api/notes/accounts",
@@ -101,25 +120,12 @@ export async function listNotesAccounts(command, door) {
101
120
  if (accounts.length === 0) {
102
121
  writeLine(door.io.stdout, "You have not connected a notetaker of your own.");
103
122
  writeSharedFeeds(door, sharedFeeds);
104
- writeLine(door.io.stdout, "");
105
- writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
106
- writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
107
- writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (opens Wispr's sign-in in your browser and waits; no key)");
123
+ writeHowToConnect(door);
108
124
  if (coverage)
109
125
  printCoverage(door, coverage);
110
126
  return 0;
111
127
  }
112
- for (const account of accounts) {
113
- const health = account.status === "active"
114
- ? "active"
115
- : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
116
- // The workspace rides beside the provider because a Fellow account that
117
- // points at the wrong subdomain fails with a 401 and nothing else says so.
118
- const where = account.workspace ? `${account.provider}/${account.workspace}` : account.provider;
119
- writeLine(door.io.stdout, `${(account.label ?? account.provider).padEnd(20)} ${where.padEnd(20)} ${health.padEnd(24)} ${account.last_synced_at ?? "never read"}`);
120
- }
121
- writeLine(door.io.stdout, "");
122
- writeLine(door.io.stdout, `${accounts.length} notetaker(s). ids: ${accounts.map((account) => account.id).join(", ")}`);
128
+ writeAccountRows(door, accounts);
123
129
  writeSharedFeeds(door, sharedFeeds);
124
130
  if (coverage)
125
131
  printCoverage(door, coverage);
@@ -159,21 +165,10 @@ export async function connectNotetaker(command, door, wisprflow = {}) {
159
165
  if (isInteractiveStdin(door.io)) {
160
166
  return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
161
167
  }
162
- let apiKey;
163
- try {
164
- apiKey = (await readPipedText(door.io.stdin, {
165
- // A pre-built Wispr Flow sign-in bundle carries an OAuth refresh token,
166
- // which can be far longer than any API key.
167
- maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
168
- overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
169
- })).trim();
170
- }
171
- catch (error) {
172
- return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
173
- }
174
- if (apiKey === "") {
175
- return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
176
- }
168
+ const key = await readConnectKey(command, door);
169
+ if ("exitCode" in key)
170
+ return key.exitCode;
171
+ const apiKey = key.apiKey;
177
172
  const answer = await ask(door, {
178
173
  path: "/api/notes/accounts",
179
174
  method: "POST",
@@ -194,10 +189,32 @@ export async function connectNotetaker(command, door, wisprflow = {}) {
194
189
  const account = answer.body.account;
195
190
  if (door.json)
196
191
  return emit(door, { ok: true, account });
197
- const named = account?.provider ?? command.provider ?? "fellow";
192
+ writeConnected(door, account, command.provider ?? "fellow");
193
+ return 0;
194
+ }
195
+ /** The key as piped on stdin, or the exit code of the refusal already written. */
196
+ async function readConnectKey(command, door) {
197
+ let apiKey;
198
+ try {
199
+ apiKey = (await readPipedText(door.io.stdin, {
200
+ // A pre-built Wispr Flow sign-in bundle carries an OAuth refresh token,
201
+ // which can be far longer than any API key.
202
+ maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
203
+ overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
204
+ })).trim();
205
+ }
206
+ catch (error) {
207
+ return { exitCode: fail(door, "key_unreadable", error instanceof Error ? error.message : String(error)) };
208
+ }
209
+ if (apiKey === "") {
210
+ return { exitCode: fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.") };
211
+ }
212
+ return { apiKey };
213
+ }
214
+ function writeConnected(door, account, requestedProvider) {
215
+ const named = account?.provider ?? requestedProvider;
198
216
  writeLine(door.io.stdout, `Connected ${account?.workspace ? `${named}/${account.workspace}` : named} (${account?.id ?? "no id returned"}).`);
199
217
  writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit notes sync <id>`, or wait for the half-hourly cron.");
200
- return 0;
201
218
  }
202
219
  /**
203
220
  * `cockpit notes accounts --show-webhook <id>` and
@@ -44,96 +44,122 @@ export async function syncNotetaker(command, door) {
44
44
  // and the MCP tool call; nothing here reads a notetaker itself.
45
45
  const env = door.io.env ?? {};
46
46
  const pollMs = Number.parseInt(env["COCKPIT_NOTES_SYNC_POLL_MS"] ?? "", 10);
47
- const liveWaitMs = Number.isFinite(pollMs) && pollMs >= 0 ? pollMs : LIVE_PASS_WAIT_MS;
48
- const totals = { stored: 0, unchanged: 0, skipped: 0 };
49
- // BLI-18006: `--repair-truncated` asks the same door to re-read Wispr Flow notes
50
- // that were stored cut short. Fixed ones count as "unchanged" in the totals
51
- // (the ledger's column), failed ones as "skipped"; the repair line says which.
52
- const repairing = command.repairTruncated === true;
53
- const repairTotals = { fixed: 0, failed: 0, found: 0 };
54
- let passes = 0;
55
- let stalled = 0;
56
- let liveWaits = 0;
57
- let transientFailures = 0;
58
- let last;
59
- while (passes < MAX_SYNC_PASSES) {
60
- const answer = await ask(door, {
61
- path: `/api/notes/accounts/${encodeURIComponent(accountId)}/sync`,
62
- method: "POST",
63
- label: repairing ? "notes sync (repair truncated)" : "notes sync",
64
- timeoutMs: repairing ? REPAIR_DEADLINE_MS : WRITE_DEADLINE_MS,
65
- ...(repairing ? { body: { repair: "truncated_transcripts" } } : {}),
66
- });
67
- if (!answer.ok) {
68
- // A timeout or a 5xx mid-history is the call's, not the account's: the
69
- // cursor was saved after every meeting, so asking again resumes. Three in
70
- // a row is a real outage and is reported with its reason.
71
- const transient = /^(timeout|network|unreachable|http_5\d\d|BLI-E5\d\d|read_failed)/i.test(answer.reason);
72
- if (transient && transientFailures < MAX_TRANSIENT_FAILURES) {
73
- transientFailures += 1;
74
- writeLine(door.io.stderr, `${TAG} sync call failed, retrying ${JSON.stringify({ reason: answer.reason, attempt: transientFailures })}`);
75
- await sleepMs(liveWaitMs);
76
- continue;
77
- }
78
- return failRun(door, answer.reason, answer.detail, last, passes, totals);
79
- }
80
- transientFailures = 0;
81
- const run = answer.body.run;
82
- if (!run) {
83
- writeLine(door.io.stdout, "Tower answered without a run.");
84
- return failRun(door, "no_run_in_answer", "Tower answered the sync without a run.", last, passes, totals);
85
- }
86
- last = run;
87
- if (run.reason === "running") {
88
- // Another pass (the cron, a second terminal) is live on this account. It
89
- // writes the same cursor; wait for it rather than racing it.
90
- liveWaits += 1;
91
- writeLine(door.io.stderr, `${TAG} another pass is running ${JSON.stringify({ run_id: run.runId ?? null, waits: liveWaits })}`);
92
- if (liveWaits > MAX_LIVE_WAITS) {
93
- return failRun(door, "pass_still_running", "Another pass kept running on this notetaker. Check `cockpit notes sync-status`, then try again.", last, passes, totals);
94
- }
95
- await sleepMs(liveWaitMs);
96
- continue;
97
- }
98
- passes += 1;
99
- totals.stored += run.notesAdded;
100
- totals.unchanged += run.notesUpdated;
101
- totals.skipped += run.notesSkipped;
102
- const waiting = typeof run.remaining === "number" ? `; ${run.remaining} waiting` : "";
103
- if (run.repair) {
104
- repairTotals.fixed += run.repair.fixed;
105
- repairTotals.failed += run.repair.failed;
106
- repairTotals.found = Math.max(repairTotals.found, run.repair.found);
107
- }
108
- const line = run.repair
109
- ? `pass ${passes}: ${run.reason}: repair: ${repairLine(run.repair)}${run.more ? ", continuing" : ""}.`
110
- : `pass ${passes}: ${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped${run.more ? `${waiting}, continuing` : ""}.`;
111
- // Progress is commentary in --json (stdout is the one answer), and the
112
- // answer in a terminal.
113
- writeLine(door.json ? door.io.stderr : door.io.stdout, line);
114
- if (run.detail && !run.ok)
115
- writeLine(door.json ? door.io.stderr : door.io.stdout, run.detail);
116
- if (!run.ok) {
117
- return failRun(door, run.reason, run.detail || `The pass ended ${run.reason}.`, run, passes, totals);
118
- }
119
- if (!run.more) {
120
- if (door.json)
121
- return emit(door, { ok: true, passes, totals, ...(repairing ? { repair: repairTotals } : {}), run });
122
- writeLine(door.io.stdout, repairing
123
- ? `Repair finished after ${passes} pass${passes === 1 ? "" : "es"}: ${repairTotals.fixed} fixed, ${repairTotals.failed} failed (${repairTotals.found} carried the marker when the first pass began).`
124
- : `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
125
- return 0;
126
- }
127
- // A pass that handled nothing and still says "more" would loop forever.
128
- if (run.notesAdded + run.notesUpdated + run.notesSkipped === 0)
129
- stalled += 1;
130
- else
131
- stalled = 0;
132
- if (stalled >= MAX_STALLED_PASSES) {
133
- return failRun(door, "no_progress", `Three passes in a row handled no meeting (last: ${run.reason}). The cursor is saved; try again later or check \`cockpit notes sync-status\`.`, run, passes, totals);
134
- }
47
+ const settings = {
48
+ accountId,
49
+ // BLI-18006: `--repair-truncated` asks the same door to re-read Wispr Flow notes
50
+ // that were stored cut short. Fixed ones count as "unchanged" in the totals
51
+ // (the ledger's column), failed ones as "skipped"; the repair line says which.
52
+ repairing: command.repairTruncated === true,
53
+ liveWaitMs: Number.isFinite(pollMs) && pollMs >= 0 ? pollMs : LIVE_PASS_WAIT_MS,
54
+ };
55
+ const state = {
56
+ passes: 0,
57
+ stalled: 0,
58
+ liveWaits: 0,
59
+ transientFailures: 0,
60
+ last: undefined,
61
+ totals: { stored: 0, unchanged: 0, skipped: 0 },
62
+ repairTotals: { fixed: 0, failed: 0, found: 0 },
63
+ };
64
+ while (state.passes < MAX_SYNC_PASSES) {
65
+ const outcome = await runOnePass(door, settings, state);
66
+ if (outcome !== "again")
67
+ return outcome;
135
68
  }
136
- return failRun(door, "too_many_passes", `Stopped after ${MAX_SYNC_PASSES} passes with meetings still waiting. Run \`cockpit notes sync ${accountId}\` again to continue from the saved cursor.`, last, passes, totals);
69
+ return stopSync(door, state, "too_many_passes", `Stopped after ${MAX_SYNC_PASSES} passes with meetings still waiting. Run \`cockpit notes sync ${accountId}\` again to continue from the saved cursor.`);
70
+ }
71
+ /** One door call and what follows it: an exit code when the sync is over, "again" when it is not. */
72
+ async function runOnePass(door, settings, state) {
73
+ const answer = await ask(door, {
74
+ path: `/api/notes/accounts/${encodeURIComponent(settings.accountId)}/sync`,
75
+ method: "POST",
76
+ label: settings.repairing ? "notes sync (repair truncated)" : "notes sync",
77
+ timeoutMs: settings.repairing ? REPAIR_DEADLINE_MS : WRITE_DEADLINE_MS,
78
+ ...(settings.repairing ? { body: { repair: "truncated_transcripts" } } : {}),
79
+ });
80
+ if (!answer.ok)
81
+ return await retryOrStop(door, settings, state, answer);
82
+ state.transientFailures = 0;
83
+ const run = answer.body.run;
84
+ if (!run) {
85
+ writeLine(door.io.stdout, "Tower answered without a run.");
86
+ return stopSync(door, state, "no_run_in_answer", "Tower answered the sync without a run.");
87
+ }
88
+ state.last = run;
89
+ if (run.reason === "running")
90
+ return await waitForLivePass(door, settings, state, run);
91
+ recordPass(door, state, run);
92
+ if (!run.ok) {
93
+ return stopSync(door, state, run.reason, run.detail || `The pass ended ${run.reason}.`);
94
+ }
95
+ if (!run.more)
96
+ return finishSync(door, settings, state, run);
97
+ return checkProgress(door, state, run);
98
+ }
99
+ /** A timeout or a 5xx mid-history is the call's, not the account's: the cursor was saved after every meeting. */
100
+ async function retryOrStop(door, settings, state, answer) {
101
+ // Asking again resumes. Three in a row is a real outage and is reported with its reason.
102
+ const transient = /^(timeout|network|unreachable|http_5\d\d|BLI-E5\d\d|read_failed)/i.test(answer.reason);
103
+ if (!transient || state.transientFailures >= MAX_TRANSIENT_FAILURES) {
104
+ return stopSync(door, state, answer.reason, answer.detail);
105
+ }
106
+ state.transientFailures += 1;
107
+ writeLine(door.io.stderr, `${TAG} sync call failed, retrying ${JSON.stringify({ reason: answer.reason, attempt: state.transientFailures })}`);
108
+ await sleepMs(settings.liveWaitMs);
109
+ return "again";
110
+ }
111
+ /** Another pass (the cron, a second terminal) is live on this account. It writes the same cursor; wait for it rather than racing it. */
112
+ async function waitForLivePass(door, settings, state, run) {
113
+ state.liveWaits += 1;
114
+ writeLine(door.io.stderr, `${TAG} another pass is running ${JSON.stringify({ run_id: run.runId ?? null, waits: state.liveWaits })}`);
115
+ if (state.liveWaits > MAX_LIVE_WAITS) {
116
+ return stopSync(door, state, "pass_still_running", "Another pass kept running on this notetaker. Check `cockpit notes sync-status`, then try again.");
117
+ }
118
+ await sleepMs(settings.liveWaitMs);
119
+ return "again";
120
+ }
121
+ /** Add the pass to the totals and print its line. */
122
+ function recordPass(door, state, run) {
123
+ state.passes += 1;
124
+ state.totals.stored += run.notesAdded;
125
+ state.totals.unchanged += run.notesUpdated;
126
+ state.totals.skipped += run.notesSkipped;
127
+ const waiting = typeof run.remaining === "number" ? `; ${run.remaining} waiting` : "";
128
+ if (run.repair) {
129
+ state.repairTotals.fixed += run.repair.fixed;
130
+ state.repairTotals.failed += run.repair.failed;
131
+ state.repairTotals.found = Math.max(state.repairTotals.found, run.repair.found);
132
+ }
133
+ const line = run.repair
134
+ ? `pass ${state.passes}: ${run.reason}: repair: ${repairLine(run.repair)}${run.more ? ", continuing" : ""}.`
135
+ : `pass ${state.passes}: ${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped${run.more ? `${waiting}, continuing` : ""}.`;
136
+ // Progress is commentary in --json (stdout is the one answer), and the
137
+ // answer in a terminal.
138
+ writeLine(door.json ? door.io.stderr : door.io.stdout, line);
139
+ if (run.detail && !run.ok)
140
+ writeLine(door.json ? door.io.stderr : door.io.stdout, run.detail);
141
+ }
142
+ function finishSync(door, settings, state, run) {
143
+ const { passes, totals, repairTotals } = state;
144
+ if (door.json)
145
+ return emit(door, { ok: true, passes, totals, ...(settings.repairing ? { repair: repairTotals } : {}), run });
146
+ writeLine(door.io.stdout, settings.repairing
147
+ ? `Repair finished after ${passes} pass${passes === 1 ? "" : "es"}: ${repairTotals.fixed} fixed, ${repairTotals.failed} failed (${repairTotals.found} carried the marker when the first pass began).`
148
+ : `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
149
+ return 0;
150
+ }
151
+ /** A pass that handled nothing and still says "more" would loop forever. */
152
+ function checkProgress(door, state, run) {
153
+ if (run.notesAdded + run.notesUpdated + run.notesSkipped === 0)
154
+ state.stalled += 1;
155
+ else
156
+ state.stalled = 0;
157
+ if (state.stalled < MAX_STALLED_PASSES)
158
+ return "again";
159
+ return stopSync(door, state, "no_progress", `Three passes in a row handled no meeting (last: ${run.reason}). The cursor is saved; try again later or check \`cockpit notes sync-status\`.`);
160
+ }
161
+ function stopSync(door, state, reason, detail) {
162
+ return failRun(door, reason, detail, state.last, state.passes, state.totals);
137
163
  }
138
164
  /** A sync that did not finish: the reason on stderr, the structured answer on stdout in --json. */
139
165
  function failRun(door, reason, detail, run, passes, totals) {
@@ -57,8 +57,14 @@ export async function connectWisprflowInBrowser(door, options = {}) {
57
57
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
58
58
  const now = options.now ?? Date.now;
59
59
  const open = options.open ?? ((url) => openInBrowser(url));
60
- const stderr = door.io.stderr;
61
- // 1. Ask the one start door for a sign-in.
60
+ const started = await startSignIn(door);
61
+ if ("failure" in started)
62
+ return started.failure;
63
+ announceSignIn(door, started.signIn.authorizeUrl, open);
64
+ return await waitForSignIn(door, started.signIn.attemptId, sleep, now);
65
+ }
66
+ /** 1. Ask the one start door for a sign-in. */
67
+ async function startSignIn(door) {
62
68
  const started = await ask(door, {
63
69
  path: WISPRFLOW_START_PATH,
64
70
  method: "POST",
@@ -69,8 +75,8 @@ export async function connectWisprflowInBrowser(door, options = {}) {
69
75
  if (!started.ok) {
70
76
  log(door, "start refused", { reason: started.reason });
71
77
  if (started.reason === "account_already_connected")
72
- return alreadyConnected(door);
73
- return { ok: false, reason: started.reason, detail: started.detail };
78
+ return { failure: await alreadyConnected(door) };
79
+ return { failure: { ok: false, reason: started.reason, detail: started.detail } };
74
80
  }
75
81
  const body = started.body;
76
82
  const authorizeUrl = typeof body.authorize_url === "string" ? body.authorize_url : "";
@@ -78,13 +84,19 @@ export async function connectWisprflowInBrowser(door, options = {}) {
78
84
  if (authorizeUrl === "" || attemptId === "") {
79
85
  log(door, "start answer unusable", { reason: "start_answer_unusable" });
80
86
  return {
81
- ok: false,
82
- reason: "start_answer_unusable",
83
- detail: "Tower started a sign-in but did not say where to send you or how to watch it, so nothing was connected.",
87
+ failure: {
88
+ ok: false,
89
+ reason: "start_answer_unusable",
90
+ detail: "Tower started a sign-in but did not say where to send you or how to watch it, so nothing was connected.",
91
+ },
84
92
  };
85
93
  }
86
94
  log(door, "started", { attempt_id: attemptId, expires_at: typeof body.expires_at === "string" ? body.expires_at : null });
87
- // 2. Print the address first, then open it.
95
+ return { signIn: { attemptId, authorizeUrl } };
96
+ }
97
+ /** 2. Print the address first, then open it. */
98
+ function announceSignIn(door, authorizeUrl, open) {
99
+ const stderr = door.io.stderr;
88
100
  writeLine(stderr, "Connect Wispr Flow to Tower: approve on Wispr's page in your browser.");
89
101
  writeLine(stderr, ` ${authorizeUrl}`);
90
102
  writeLine(stderr, " Use the Wispr account that holds your notes, signed in with Google, Apple, Microsoft or company single sign-on.");
@@ -93,7 +105,9 @@ export async function connectWisprflowInBrowser(door, options = {}) {
93
105
  if (!opened.ok)
94
106
  writeLine(stderr, `(${opened.detail} The address above still works.)`);
95
107
  writeLine(stderr, `Waiting up to ${MAX_WAIT_MS / 60_000} minutes for the sign-in to finish...`);
96
- // 3. Poll the one status door.
108
+ }
109
+ /** 3. Poll the one status door. */
110
+ async function waitForSignIn(door, attemptId, sleep, now) {
97
111
  const deadline = now() + MAX_WAIT_MS;
98
112
  let polls = 0;
99
113
  let failures = 0;
@@ -115,20 +129,9 @@ export async function connectWisprflowInBrowser(door, options = {}) {
115
129
  }
116
130
  else {
117
131
  failures = 0;
118
- const status = polled.body;
119
- const state = typeof status.status === "string" ? status.status : "unknown";
120
- const reason = typeof status.reason === "string" ? status.reason : null;
121
- log(door, "poll", { attempt_id: attemptId, polls, status: state, reason });
122
- if (state === "connected") {
123
- const accountId = typeof status.account_id === "string" ? status.account_id : "";
124
- log(door, "connected", { attempt_id: attemptId, account_id: accountId || null, polls });
125
- return { ok: true, account: await readAccount(door, accountId), already: false };
126
- }
127
- if (state === "failed" || state === "expired") {
128
- const why = reason ?? (state === "expired" ? "state_expired" : "unknown");
129
- log(door, "ended without connecting", { attempt_id: attemptId, status: state, reason: why });
130
- return { ok: false, reason: why, detail: failureDetail(why) };
131
- }
132
+ const ended = await readEndOfSignIn(door, attemptId, polls, polled.body);
133
+ if (ended)
134
+ return ended;
132
135
  }
133
136
  await sleep(POLL_INTERVAL_MS);
134
137
  }
@@ -140,6 +143,24 @@ export async function connectWisprflowInBrowser(door, options = {}) {
140
143
  + " Run this again for a fresh link, or check `cockpit notes accounts`.",
141
144
  };
142
145
  }
146
+ /** The sign-in's answer once it has one; undefined while it is still pending. */
147
+ async function readEndOfSignIn(door, attemptId, polls, pollBody) {
148
+ const status = pollBody;
149
+ const state = typeof status.status === "string" ? status.status : "unknown";
150
+ const reason = typeof status.reason === "string" ? status.reason : null;
151
+ log(door, "poll", { attempt_id: attemptId, polls, status: state, reason });
152
+ if (state === "connected") {
153
+ const accountId = typeof status.account_id === "string" ? status.account_id : "";
154
+ log(door, "connected", { attempt_id: attemptId, account_id: accountId || null, polls });
155
+ return { ok: true, account: await readAccount(door, accountId), already: false };
156
+ }
157
+ if (state === "failed" || state === "expired") {
158
+ const why = reason ?? (state === "expired" ? "state_expired" : "unknown");
159
+ log(door, "ended without connecting", { attempt_id: attemptId, status: state, reason: why });
160
+ return { ok: false, reason: why, detail: failureDetail(why) };
161
+ }
162
+ return undefined;
163
+ }
143
164
  /** The start door said Wispr Flow is already connected: read which account and report it honestly. */
144
165
  async function alreadyConnected(door) {
145
166
  const accounts = await readAccounts(door);