@bli-cockpit/cli 0.2.117 → 0.2.121

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 (42) hide show
  1. package/dist/commands/analyze.js +74 -54
  2. package/dist/commands/brief-rewrite.js +164 -101
  3. package/dist/commands/brief.js +38 -13
  4. package/dist/commands/correct.js +38 -21
  5. package/dist/commands/docs.js +13 -10
  6. package/dist/commands/editor.js +59 -30
  7. package/dist/commands/install-receipts.js +106 -91
  8. package/dist/commands/local-args-tower-admin.js +4 -0
  9. package/dist/commands/local-args-tower-cal.js +28 -3
  10. package/dist/commands/local-args-tower-chat.js +55 -28
  11. package/dist/commands/local-args-tower-docs-msg.js +39 -8
  12. package/dist/commands/local-args-tower-mail.js +27 -1
  13. package/dist/commands/local-args-tower-models.js +14 -17
  14. package/dist/commands/local-args-tower-pages.js +62 -5
  15. package/dist/commands/local-args-tower-work.js +37 -6
  16. package/dist/commands/local-help-commands.js +6 -3
  17. package/dist/commands/local-help.js +1 -1
  18. package/dist/commands/mcp-stdio-probe.js +92 -73
  19. package/dist/commands/memory-hook-performance.js +135 -101
  20. package/dist/commands/memory-install-claude.js +15 -14
  21. package/dist/commands/memory-install-codex.js +10 -6
  22. package/dist/commands/memory-install-config.js +5 -4
  23. package/dist/commands/memory-install-contract.js +56 -10
  24. package/dist/commands/memory-install-report.js +16 -11
  25. package/dist/commands/memory-install-skills.js +11 -11
  26. package/dist/commands/memory-log.js +22 -5
  27. package/dist/commands/msg.js +11 -5
  28. package/dist/commands/notes-accounts.js +96 -5
  29. package/dist/commands/notes.js +10 -3
  30. package/dist/commands/onboard-setup.js +16 -1
  31. package/dist/commands/ops-sections.js +89 -0
  32. package/dist/commands/ops.js +117 -120
  33. package/dist/commands/public-root.js +1 -1
  34. package/dist/commands/scout.js +90 -68
  35. package/dist/commands/session-sync-failures.js +19 -13
  36. package/dist/commands/session-sync-record.js +53 -52
  37. package/dist/commands/session-sync-upload.js +15 -11
  38. package/dist/commands/sessions.js +61 -51
  39. package/dist/commands/slack.js +90 -61
  40. package/dist/commands/status.js +53 -41
  41. package/dist/commands/workbook.js +23 -20
  42. package/package.json +2 -2
@@ -49,6 +49,12 @@
49
49
  * machine resolved before anything is written. Skipping that step registered
50
50
  * three hooks that answered `command not found` on every turn.
51
51
  *
52
+ * **And the path it is re-pointed at is written for a SHELL, not for the
53
+ * filesystem** — `shellSafeBinPath`. A hook command is a shell string, Windows
54
+ * runs it through bash, and a native path's backslashes are eaten as escapes:
55
+ * the same three hooks, the same `command not found`, arrived at from the other
56
+ * direction (BLI-4136).
57
+ *
52
58
  * Nothing here touches the filesystem. The halves that do are
53
59
  * `memory-install-claude.ts` and `memory-install-codex.ts`.
54
60
  */
@@ -96,16 +102,56 @@ export const MEMORY_AUTO_APPROVE_TOOLS = [
96
102
  ];
97
103
  /** Characters that would let a resolved path change the meaning of a hook command string. */
98
104
  const UNSAFE_PATH_CHARACTERS = /["'`$;&|<>\r\n]/u;
105
+ /**
106
+ * Windows device paths — `\\?\C:\…` (extended length) and `\\.\…` (device
107
+ * namespace). Both prefixes are DEFINED in terms of backslashes and stop being
108
+ * that prefix the moment `shellSafeBinPath` turns them into forward slashes, so
109
+ * a hook command cannot carry one without silently naming a different file.
110
+ * Refused with a name rather than rewritten into something plausible.
111
+ */
112
+ const WINDOWS_DEVICE_PATH = /^\\\\[?.]\\/u;
99
113
  export function isUnsafeBinPath(binPath) {
100
- return UNSAFE_PATH_CHARACTERS.test(binPath);
114
+ return UNSAFE_PATH_CHARACTERS.test(binPath) || WINDOWS_DEVICE_PATH.test(binPath);
101
115
  }
102
116
  /**
103
- * A Claude Code hook `command` is a shell string by the platform's design, so
104
- * the only defence is quoting — and a path that could not be quoted safely is
105
- * refused upstream (see `resolveMemoryMcpBin`) rather than escaped cleverly.
117
+ * A path that no shell gives a meaning to, so it needs no quoting. Deliberately
118
+ * a tight allow-list rather than a list of known-bad characters: `(` and `)` in
119
+ * `C:/Program Files (x86)/…` are a bash syntax error, and the earlier
120
+ * whitespace-only trigger caught them only by the accident of the space.
121
+ */
122
+ const PLAINLY_SAFE_PATH = /^[A-Za-z0-9_@:./+,-]+$/u;
123
+ /**
124
+ * The resolved bin path, as it must appear INSIDE a hook command string.
125
+ *
126
+ * A Claude Code hook `command` is a shell string by the platform's design, and
127
+ * on Windows the host runs it through **bash** — which eats every backslash as
128
+ * an escape. So the native path this installer resolved,
129
+ * `C:\Users\…\.bin\bli-memory-mcp.cmd`, reaches the shell as
130
+ * `C:Users….binbli-memory-mcp.cmd` and answers `command not found` on every
131
+ * SessionStart, every prompt and every Stop. Non-blocking and therefore silent:
132
+ * memory recall simply degrades, and nothing says so (BLI-4136, fixed by hand
133
+ * twice on the same machine before the installer was the thing that changed).
134
+ *
135
+ * Two independent defences, because either one alone is a thin edge:
136
+ *
137
+ * 1. **Separators are normalised to `/` on Windows.** Win32 accepts forward
138
+ * slashes in every path it resolves, and after this there is no escape
139
+ * character left in the string for a shell to consume. This is also what
140
+ * survives a `\\`: double-quoting ALONE would collapse the UNC prefix of
141
+ * `\\server\share\…` back to one backslash and name the wrong path.
142
+ * 2. **Anything not plainly safe is double-quoted**, which covers spaces and
143
+ * the bracket characters in `Program Files (x86)`.
144
+ *
145
+ * A path that still could not be expressed safely is refused upstream
146
+ * (`isUnsafeBinPath`, checked in `resolveMemoryConfig`) rather than escaped
147
+ * cleverly.
148
+ *
149
+ * POSIX keeps its separators untouched — a backslash there is a legal
150
+ * character in a filename, and rewriting it would name a different file.
106
151
  */
107
- export function shellQuoteBinPath(binPath) {
108
- return /\s/u.test(binPath) ? `"${binPath}"` : binPath;
152
+ export function shellSafeBinPath(binPath, platform) {
153
+ const forShell = platform === "win32" ? binPath.replace(/\\/gu, "/") : binPath;
154
+ return PLAINLY_SAFE_PATH.test(forShell) ? forShell : `"${forShell}"`;
109
155
  }
110
156
  /**
111
157
  * Windows cannot spawn an npm `.cmd` shim directly — Node refuses it without a
@@ -125,13 +171,13 @@ export function memoryMcpServerEntry(options) {
125
171
  return { command: options.binPath, args: [], env };
126
172
  }
127
173
  export function builtinMemoryInstallConfig(options) {
128
- const quoted = shellQuoteBinPath(options.binPath);
174
+ const binPath = shellSafeBinPath(options.binPath, options.platform);
129
175
  return {
130
176
  server_id: MEMORY_MCP_SERVER_ID,
131
177
  mcp_server: memoryMcpServerEntry(options),
132
178
  hooks: MEMORY_HOOK_EVENTS.map((event) => ({
133
179
  event,
134
- command: `${quoted} ${MEMORY_HOOK_SUBCOMMAND[event]}`,
180
+ command: `${binPath} ${MEMORY_HOOK_SUBCOMMAND[event]}`,
135
181
  timeout_seconds: MEMORY_HOOK_TIMEOUT_SECONDS[event],
136
182
  })),
137
183
  permissions_allow: [...MEMORY_AUTO_APPROVE_TOOLS],
@@ -237,13 +283,13 @@ export function parsePrintedMemoryInstallConfig(stdout) {
237
283
  * hook that runs something else.
238
284
  */
239
285
  export function withResolvedBinPath(config, options) {
240
- const quoted = shellQuoteBinPath(options.binPath);
286
+ const binPath = shellSafeBinPath(options.binPath, options.platform);
241
287
  const hooks = [];
242
288
  for (const hook of config.hooks) {
243
289
  const tail = hookSubcommandTail(hook.command);
244
290
  if (!tail)
245
291
  return null;
246
- hooks.push({ ...hook, command: `${quoted} ${tail}` });
292
+ hooks.push({ ...hook, command: `${binPath} ${tail}` });
247
293
  }
248
294
  const entry = memoryMcpServerEntry(options);
249
295
  return {
@@ -83,17 +83,7 @@ export function logMemoryOutcome(outcome, platform) {
83
83
  : "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
84
84
  }
85
85
  export function memoryOutcomeLines(outcome) {
86
- const headline = outcome.status === "installed"
87
- ? "BLI Memory registered on this machine."
88
- : outcome.status === "already"
89
- ? "BLI Memory is already registered on this machine."
90
- : outcome.status === "would_install"
91
- ? "BLI Memory would be registered (dry run; nothing was written)."
92
- : outcome.status === "missing"
93
- ? "BLI Memory is not registered on this machine."
94
- : outcome.status === "skipped"
95
- ? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
96
- : `BLI Memory is not fully registered: ${outcome.reason}.`;
86
+ const headline = memoryOutcomeHeadline(outcome);
97
87
  const lines = [headline, ` ${memoryReceiptLine(outcome.receipt)}`];
98
88
  // BLI-3884. Only `status` asks; an absent reading is not printed as "no
99
89
  // daemon", because nothing looked.
@@ -115,4 +105,19 @@ export function memoryOutcomeLines(outcome) {
115
105
  lines.push(` ${target.target}: ${target.status} (${target.reason})${where}${detail}`);
116
106
  }
117
107
  return lines;
108
+ }
109
+ function memoryOutcomeHeadline(outcome) {
110
+ if (outcome.status === "installed")
111
+ return "BLI Memory registered on this machine.";
112
+ if (outcome.status === "already")
113
+ return "BLI Memory is already registered on this machine.";
114
+ if (outcome.status === "would_install") {
115
+ return "BLI Memory would be registered (dry run; nothing was written).";
116
+ }
117
+ if (outcome.status === "missing")
118
+ return "BLI Memory is not registered on this machine.";
119
+ if (outcome.status === "skipped") {
120
+ return "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet.";
121
+ }
122
+ return `BLI Memory is not fully registered: ${outcome.reason}.`;
118
123
  }
@@ -13,16 +13,6 @@
13
13
  * install is idempotent to the byte and a drifted copy is replaced rather than
14
14
  * merged — the same deal `cockpit agent-rules` offers for its managed block.
15
15
  */
16
- /** Relative path → exact file contents. The map IS the install. */
17
- export function memoryCodexSkillFiles() {
18
- return {
19
- "SKILL.md": SKILL_MD,
20
- "references/search.md": SEARCH_MD,
21
- "references/save.md": SAVE_MD,
22
- "references/update.md": UPDATE_MD,
23
- "references/forget.md": FORGET_MD,
24
- };
25
- }
26
16
  const SKILL_MD = `---
27
17
  name: bli-memory
28
18
  description: BLI Memory — durable memory for this machine. Use when you need to recall what was decided before, or when a session produced a durable decision, preference or correction worth keeping.
@@ -118,4 +108,14 @@ again. Matching by content is exact after whitespace normalisation — never
118
108
  fuzzy, and never widened to every container.
119
109
 
120
110
  It returns what it removed. A bare "done" is not an answer.
121
- `;
111
+ `;
112
+ /** Relative path → exact file contents. The map IS the install. */
113
+ export function memoryCodexSkillFiles() {
114
+ return {
115
+ "SKILL.md": SKILL_MD,
116
+ "references/search.md": SEARCH_MD,
117
+ "references/save.md": SAVE_MD,
118
+ "references/update.md": UPDATE_MD,
119
+ "references/forget.md": FORGET_MD,
120
+ };
121
+ }
@@ -5,20 +5,37 @@ import { callTower, openTower } from "./tower-command.js";
5
5
  function sender(command, io) {
6
6
  return async (entry) => {
7
7
  const tower = await openTower("memory log", command, io);
8
- const result = await callTower(tower, { path: "/api/memory/experience", method: "POST", body: entry, label: "memory experience", timeoutMs: 5000 });
9
- return result.ok ? { ok: result.body?.ok === true, reason: "experience_not_acknowledged" } : result;
8
+ const result = await callTower(tower, {
9
+ path: "/api/memory/experience",
10
+ method: "POST",
11
+ body: entry,
12
+ label: "memory experience",
13
+ timeoutMs: 5000,
14
+ });
15
+ return result.ok
16
+ ? {
17
+ ok: result.body?.ok === true,
18
+ reason: "experience_not_acknowledged",
19
+ }
20
+ : result;
10
21
  };
11
22
  }
12
23
  export async function runMemoryLog(command, io) {
13
- const reason = command.reasonStdin ? (await readPipedText(io.stdin, { maxChars: 1002 })).trim() : command.reason;
24
+ const reason = command.reasonStdin
25
+ ? (await readPipedText(io.stdin, { maxChars: 1002 })).trim()
26
+ : command.reason;
14
27
  validateExperience(command.store, command.verdict, reason);
15
28
  const entry = await appendExperience({ store: command.store, verdict: command.verdict, reason }, {
16
- homeDir: command.homeDir, agent: "cockpit", project: path.basename(process.cwd()),
29
+ homeDir: command.homeDir,
30
+ agent: "cockpit",
31
+ project: path.basename(process.cwd()),
17
32
  });
18
33
  const delivery = await shipExperience(entry, sender(command, io), command.homeDir);
19
34
  const receipt = { ok: true, id: entry.id, local: true, ...delivery };
20
35
  writeLine(io.stderr, `[memory experience] recorded ${JSON.stringify(receipt)}`);
21
- writeLine(io.stdout, command.json ? JSON.stringify(receipt) : `Experience appended; shipped: ${delivery.shipped} (${delivery.reason}).`);
36
+ writeLine(io.stdout, command.json
37
+ ? JSON.stringify(receipt)
38
+ : `Experience appended; shipped: ${delivery.shipped} (${delivery.reason}).`);
22
39
  return 0;
23
40
  }
24
41
  export async function runMemoryExperienceAfterSync(command, io) {
@@ -220,16 +220,23 @@ async function createChannel(command, door) {
220
220
  const membersAdded = body.members_added ?? [];
221
221
  const membersFailed = body.members_failed ?? [];
222
222
  const requestedMembers = command.memberEmails?.length ?? 0;
223
+ logChannelCreation(door, channel, command.isPrivate, requestedMembers, membersAdded, membersFailed);
224
+ if (door.json) {
225
+ return emitAgentDoor(door, { ok: true, channel, membersAdded, membersFailed });
226
+ }
227
+ reportChannelCreationToHuman(door, channel, name, requestedMembers, membersAdded, membersFailed);
228
+ return 0;
229
+ }
230
+ function logChannelCreation(door, channel, isPrivate, requestedMembers, membersAdded, membersFailed) {
223
231
  writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({
224
232
  channel_id: channel?.id ?? null,
225
- is_private: command.isPrivate,
233
+ is_private: isPrivate,
226
234
  members_requested: requestedMembers,
227
235
  members_added: membersAdded.length,
228
236
  members_failed: membersFailed.length,
229
237
  })}`);
230
- if (door.json) {
231
- return emitAgentDoor(door, { ok: true, channel, membersAdded, membersFailed });
232
- }
238
+ }
239
+ function reportChannelCreationToHuman(door, channel, name, requestedMembers, membersAdded, membersFailed) {
233
240
  writeLine(door.io.stdout, `Created #${channel?.name ?? name} (${channel?.id ?? "?"}).`);
234
241
  if (membersAdded.length > 0) {
235
242
  writeLine(door.io.stdout, `${membersAdded.length} member(s) added.`);
@@ -246,7 +253,6 @@ async function createChannel(command, door) {
246
253
  for (const failure of membersFailed) {
247
254
  writeLine(door.io.stdout, `Not added (${failure.reason}): ${failure.userId}`);
248
255
  }
249
- return 0;
250
256
  }
251
257
  async function openDm(command, door) {
252
258
  const email = command.dmEmail ?? "";
@@ -37,14 +37,18 @@ export async function listNotesAccounts(command, door) {
37
37
  if (accounts.length === 0) {
38
38
  writeLine(door.io.stdout, "No notetaker is connected yet.");
39
39
  writeLine(door.io.stdout, "");
40
- writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fathom api key>" | cockpit notes connect --provider fathom --key-stdin');
40
+ writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
41
+ writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
41
42
  return 0;
42
43
  }
43
44
  for (const account of accounts) {
44
45
  const health = account.status === "active"
45
46
  ? "active"
46
47
  : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
47
- writeLine(door.io.stdout, `${(account.label ?? account.provider).padEnd(20)} ${account.provider.padEnd(10)} ${health.padEnd(24)} ${account.last_synced_at ?? "never read"}`);
48
+ // The workspace rides beside the provider because a Fellow account that
49
+ // points at the wrong subdomain fails with a 401 and nothing else says so.
50
+ const where = account.workspace ? `${account.provider}/${account.workspace}` : account.provider;
51
+ writeLine(door.io.stdout, `${(account.label ?? account.provider).padEnd(20)} ${where.padEnd(20)} ${health.padEnd(24)} ${account.last_synced_at ?? "never read"}`);
48
52
  }
49
53
  writeLine(door.io.stdout, "");
50
54
  writeLine(door.io.stdout, `${accounts.length} notetaker(s). ids: ${accounts.map((account) => account.id).join(", ")}`);
@@ -58,7 +62,7 @@ export async function connectNotetaker(command, door) {
58
62
  // nothing piped in is told how, rather than left waiting on a prompt that
59
63
  // would echo the secret into the scrollback.
60
64
  if (isInteractiveStdin(door.io)) {
61
- 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 fathom --key-stdin');
65
+ 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');
62
66
  }
63
67
  let apiKey;
64
68
  try {
@@ -79,8 +83,12 @@ export async function connectNotetaker(command, door) {
79
83
  label: "notes connect",
80
84
  timeoutMs: WRITE_DEADLINE_MS,
81
85
  body: {
82
- provider: command.provider ?? "fathom",
86
+ provider: command.provider ?? "fellow",
83
87
  label: command.name ?? null,
88
+ // Absent, not null, when nobody named one: the door treats a missing
89
+ // workspace and an explicit null the same, and a `--workspace` a person
90
+ // did type is never silently dropped.
91
+ ...(command.workspace ? { workspace: command.workspace } : {}),
84
92
  api_key: apiKey,
85
93
  },
86
94
  });
@@ -89,10 +97,93 @@ export async function connectNotetaker(command, door) {
89
97
  const account = answer.body.account;
90
98
  if (door.json)
91
99
  return emit(door, { ok: true, account });
92
- writeLine(door.io.stdout, `Connected ${account?.provider ?? command.provider ?? "fathom"} (${account?.id ?? "no id returned"}).`);
100
+ const named = account?.provider ?? command.provider ?? "fellow";
101
+ writeLine(door.io.stdout, `Connected ${account?.workspace ? `${named}/${account.workspace}` : named} (${account?.id ?? "no id returned"}).`);
93
102
  writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit notes sync <id>`, or wait for the half-hourly cron.");
94
103
  return 0;
95
104
  }
105
+ /**
106
+ * `cockpit notes accounts --show-webhook <id>` and
107
+ * `cockpit notes webhook <id> --secret-stdin` (BLI-4394).
108
+ *
109
+ * The URL is SHOWN, because a person cannot register a webhook they cannot
110
+ * read. The signing secret is READ FROM STDIN and never from a flag, for the
111
+ * same reason `connect` reads the API key that way: an argument list is in the
112
+ * process table, in the shell history and in every log that records a command
113
+ * line. There is deliberately no `--secret <value>`, and deliberately no MCP
114
+ * twin for this verb.
115
+ */
116
+ export async function showNotesWebhook(command, door) {
117
+ const accountId = command.accountId ?? "";
118
+ if (accountId === "") {
119
+ return fail(door, "account_required", "Name the notetaker: `cockpit notes accounts --show-webhook <account-id>`. `cockpit notes accounts` lists the ids.");
120
+ }
121
+ const answer = await ask(door, {
122
+ path: `/api/notes/accounts/${encodeURIComponent(accountId)}/webhook`,
123
+ method: "GET",
124
+ label: "notes webhook",
125
+ timeoutMs: READ_DEADLINE_MS,
126
+ });
127
+ if (!answer.ok)
128
+ return fail(door, answer.reason, answer.detail);
129
+ const webhook = answer.body.webhook;
130
+ if (door.json)
131
+ return emit(door, { ok: true, webhook });
132
+ if (!webhook?.url) {
133
+ writeLine(door.io.stdout, "This notetaker has no webhook URL.");
134
+ writeLine(door.io.stdout, "Its key was sealed before webhooks shipped. Detach it and connect it again to mint one.");
135
+ return 0;
136
+ }
137
+ writeLine(door.io.stdout, webhook.url);
138
+ writeLine(door.io.stdout, "");
139
+ writeLine(door.io.stdout, webhook.armed
140
+ ? "A signing secret is stored, so requests to that URL are verified."
141
+ : "NO SIGNING SECRET IS STORED YET, so every request to that URL is refused. That is the right way to be off.");
142
+ if (webhook.where) {
143
+ writeLine(door.io.stdout, "");
144
+ writeLine(door.io.stdout, webhook.where);
145
+ }
146
+ writeLine(door.io.stdout, "");
147
+ writeLine(door.io.stdout, `Store the secret: printf "%s" "<signing secret>" | cockpit notes webhook ${accountId} --secret-stdin`);
148
+ return 0;
149
+ }
150
+ export async function armNotesWebhook(command, door) {
151
+ const accountId = command.accountId ?? "";
152
+ if (accountId === "") {
153
+ return fail(door, "account_required", "Name the notetaker: `cockpit notes webhook <account-id> --secret-stdin`.");
154
+ }
155
+ // The secret comes from stdin and nowhere else, exactly as the API key does.
156
+ if (isInteractiveStdin(door.io)) {
157
+ return fail(door, "secret_required_on_stdin", `The signing secret is read from stdin, never from a flag. Try: printf "%s" "<signing secret>" | cockpit notes webhook ${accountId} --secret-stdin`);
158
+ }
159
+ let secret;
160
+ try {
161
+ secret = (await readPipedText(door.io.stdin, {
162
+ maxChars: KEY_MAX_CHARS,
163
+ overflowMessage: "That is longer than any webhook signing secret; nothing was sent.",
164
+ })).trim();
165
+ }
166
+ catch (error) {
167
+ return fail(door, "secret_unreadable", error instanceof Error ? error.message : String(error));
168
+ }
169
+ if (secret === "") {
170
+ return fail(door, "secret_required_on_stdin", "Nothing arrived on stdin, so nothing was stored.");
171
+ }
172
+ const answer = await ask(door, {
173
+ path: `/api/notes/accounts/${encodeURIComponent(accountId)}/webhook`,
174
+ method: "PUT",
175
+ label: "notes webhook arm",
176
+ timeoutMs: WRITE_DEADLINE_MS,
177
+ body: { signing_secret: secret },
178
+ });
179
+ if (!answer.ok)
180
+ return fail(door, answer.reason, answer.detail);
181
+ if (door.json)
182
+ return emit(door, { ok: true, armed: true, account_id: accountId });
183
+ writeLine(door.io.stdout, `Stored. Requests to ${accountId}'s webhook URL are now verified.`);
184
+ writeLine(door.io.stdout, "Until a meeting is written up nothing changes; the half-hourly sweep still reads this account either way.");
185
+ return 0;
186
+ }
96
187
  export async function detachNotetaker(command, door) {
97
188
  const accountId = command.accountId ?? "";
98
189
  const answer = await ask(door, {
@@ -40,10 +40,10 @@ import { loadPairedSession } from "../tower-client.js";
40
40
  import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
41
41
  import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
42
42
  import { runFolderCommand } from "./notes-folders.js";
43
- import { connectNotetaker, detachNotetaker, listNotesAccounts, syncNotetaker, } from "./notes-accounts.js";
43
+ import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, } from "./notes-accounts.js";
44
44
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
45
45
  export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
46
- export { connectNotetaker, detachNotetaker, listNotesAccounts, syncNotetaker, } from "./notes-accounts.js";
46
+ export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, } from "./notes-accounts.js";
47
47
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
48
48
  export async function runNotes(command, io) {
49
49
  const session = await loadPairedSession("notes", command.homeDir);
@@ -79,12 +79,19 @@ export async function runNotes(command, io) {
79
79
  case "place":
80
80
  return placeNote(command, door);
81
81
  case "accounts":
82
- return listNotesAccounts(command, door);
82
+ // BLI-4394. `--show-webhook <id>` turns the list into one account's
83
+ // webhook URL, because that is the question a person asks WHILE looking
84
+ // at the list rather than a verb of its own.
85
+ return command.showWebhook
86
+ ? showNotesWebhook(command, door)
87
+ : listNotesAccounts(command, door);
83
88
  case "connect":
84
89
  return connectNotetaker(command, door);
85
90
  case "detach":
86
91
  return detachNotetaker(command, door);
87
92
  case "sync":
88
93
  return syncNotetaker(command, door);
94
+ case "webhook":
95
+ return armNotesWebhook(command, door);
89
96
  }
90
97
  }
@@ -57,6 +57,13 @@ export async function pairForOnboarding(command, input, installEvents, io) {
57
57
  repoRoot: input.primaryRoot,
58
58
  branch: command.branch,
59
59
  });
60
+ if (await reuseInstalledOnboardSession(command, input, installedStatus, installEvents, io)) {
61
+ return null;
62
+ }
63
+ await logOutMismatchedOnboardSession(command, installedStatus, io);
64
+ return await pairOnboardDevice(command, input, installEvents, io);
65
+ }
66
+ async function reuseInstalledOnboardSession(command, input, installedStatus, installEvents, io) {
60
67
  const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
61
68
  const canReuseInstalledSession = canReuseOnboardSession(installedSession, input.claimedOwnerEmail, command.dashboardUrl);
62
69
  if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
@@ -65,14 +72,19 @@ export async function pairForOnboarding(command, input, installEvents, io) {
65
72
  if (!command.json) {
66
73
  writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
67
74
  }
68
- return null;
75
+ return true;
69
76
  }
77
+ return false;
78
+ }
79
+ async function logOutMismatchedOnboardSession(command, installedStatus, io) {
70
80
  if (installedStatus.session_state === "valid") {
71
81
  await logoutLocalCollector({ homeDir: command.homeDir });
72
82
  if (!command.json) {
73
83
  writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
74
84
  }
75
85
  }
86
+ }
87
+ async function pairOnboardDevice(command, input, installEvents, io) {
76
88
  // BLI-3731: one sign-in first. The link both signs the browser in and
77
89
  // finishes the pairing, so nobody reads a second email. Every other arm
78
90
  // below is the fallback, and it runs unchanged when this one cannot finish.
@@ -89,6 +101,9 @@ export async function pairForOnboarding(command, input, installEvents, io) {
89
101
  return linked;
90
102
  }
91
103
  }
104
+ return await pairOnboardDeviceWithAuthFallback(command, input, installEvents, io);
105
+ }
106
+ async function pairOnboardDeviceWithAuthFallback(command, input, installEvents, io) {
92
107
  const authResult = await requestPairingAccessTokenDetailed({
93
108
  dashboardUrl: command.dashboardUrl,
94
109
  email: input.claimedOwnerEmail,
@@ -0,0 +1,89 @@
1
+ import { asRecord, callTower } from "./tower-command.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export function turnAggregateSection(payload) {
4
+ const section = payload.turns ?? null;
5
+ return { section, reason: section ? "ok" : "turns_section_absent" };
6
+ }
7
+ export async function readToolRouterBoard(io, tower) {
8
+ const result = await callTower(tower, { path: "/api/ops/tool-router", label: "ops-tool-router" });
9
+ if (!result.ok) {
10
+ writeLine(io.stderr, `[ops cli] tool router not read ${JSON.stringify({ reason: result.reason, http_status: result.httpStatus ?? null })}`);
11
+ return { board: null, reason: result.reason };
12
+ }
13
+ const board = asRecord(result.body).board ?? null;
14
+ return { board, reason: board ? "ok" : "tool_router_section_absent" };
15
+ }
16
+ export function turnAggregateLine(section) {
17
+ return `TURNS ${section.turns ?? 0} turns / ${section.windowDays ?? 30}d · wall p50 ${section.wallTimeMs?.p50 ?? "n/a"}ms · p95 ${section.wallTimeMs?.p95 ?? "n/a"}ms · ${section.completion ?? "unknown"}`;
18
+ }
19
+ export function toolRouterLine(board) {
20
+ const rate = board.agreementRate === null || board.agreementRate === undefined
21
+ ? "n/a"
22
+ : `${(board.agreementRate * 100).toFixed(1)}%`;
23
+ return `TOOL ROUTER ${board.turnsRouted ?? 0} routed · agreement ${rate} · p50 ${board.p50LatencyMs ?? "n/a"}ms${board.capped ? " · capped" : ""}${board.readError ? ` · ${board.readError}` : ""}`;
24
+ }
25
+ /**
26
+ * The adoption gauge, read through its own door.
27
+ *
28
+ * Never fails the board: a `cockpit ops --memory` whose memory half is
29
+ * unreadable still prints the pipelines, the fleet and the coverage, and says
30
+ * in one line which half is missing and why. The status board's exit code is
31
+ * about collection health, and adoption is not that.
32
+ */
33
+ export async function readMemoryUsage(command, io, tower) {
34
+ const params = new URLSearchParams();
35
+ if (command.memoryDays !== undefined)
36
+ params.set("days", String(command.memoryDays));
37
+ const query = params.toString();
38
+ const result = await callTower(tower, {
39
+ path: `/api/ops/memory-usage${query ? `?${query}` : ""}`,
40
+ label: "ops-memory-usage",
41
+ });
42
+ if (!result.ok) {
43
+ writeLine(io.stderr, `[ops cli] memory usage not read ${JSON.stringify({
44
+ reason: result.reason,
45
+ http_status: result.httpStatus ?? null,
46
+ })}`);
47
+ return { section: null, hooks: null, experience: null, reason: result.reason };
48
+ }
49
+ const body = asRecord(result.body);
50
+ // BLI-3788: the hook counts ride the same answer, and their absence is a
51
+ // fact about the SERVER's version rather than about this fleet — an older
52
+ // dashboard sends no `hooks` key, and printing nothing is right there.
53
+ if (!body.memory)
54
+ return {
55
+ section: null,
56
+ hooks: body.hooks ?? null,
57
+ experience: body.experience ?? null,
58
+ reason: "memory_section_absent",
59
+ };
60
+ return {
61
+ section: body.memory,
62
+ hooks: body.hooks ?? null,
63
+ experience: body.experience ?? null,
64
+ reason: "ok",
65
+ };
66
+ }
67
+ /**
68
+ * The per-model board, read through its own door (BLI-3912).
69
+ *
70
+ * Same posture as the adoption gauge next door: it never fails the status
71
+ * board, and when it cannot be read the reason is printed where the section
72
+ * would have been. The LINES come from the server, so a terminal and the
73
+ * dashboard cannot disagree about what a model cost today.
74
+ */
75
+ export async function readModelBoardLines(io, tower) {
76
+ const result = await callTower(tower, { path: "/api/ops/models", label: "ops-models" });
77
+ if (!result.ok) {
78
+ writeLine(io.stderr, `[ops cli] model board not read ${JSON.stringify({
79
+ reason: result.reason,
80
+ http_status: result.httpStatus ?? null,
81
+ })}`);
82
+ return { lines: [], board: null, reason: result.reason };
83
+ }
84
+ const body = asRecord(result.body);
85
+ if (!body.lines || body.lines.length === 0) {
86
+ return { lines: [], board: body.board ?? null, reason: "models_section_absent" };
87
+ }
88
+ return { lines: body.lines, board: body.board ?? null, reason: "ok" };
89
+ }