@bli-cockpit/mcp 0.1.121 → 0.1.125

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/README.md CHANGED
@@ -109,7 +109,7 @@ about what an agent can reach.
109
109
 
110
110
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
111
111
 
112
- **171 of 176 Tower verbs have an MCP twin.**
112
+ **188 of 193 Tower verbs have an MCP twin.**
113
113
  Each tool goes through the SAME door its CLI verb calls, with the same
114
114
  collector device token — never a second route and never a service-role
115
115
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -206,6 +206,22 @@ three tables below.
206
206
  | `cockpit mail send` | `mail_send` | `POST /api/mail/send` |
207
207
  | `cockpit mail sync` | `mail_sync` | `POST /api/mail/accounts/[id]/sync` |
208
208
  | `cockpit mail sync-status` | `mail_sync_status` | `GET /api/mail/sync-status` |
209
+ | `cockpit manager add` | `manager_add` | `POST /api/manager/clients/[clientId]/actions (or /timeline)` |
210
+ | `cockpit manager approve` | `manager_approve` | `POST /api/manager/actions/[id]/review (or /timeline/[id]/review)` |
211
+ | `cockpit manager archive` | `manager_archive` | `POST /api/manager/actions/[id]/archive (or /timeline/[id]/archive)` |
212
+ | `cockpit manager client` | `manager_client` | `GET /api/manager/clients/[clientId]` |
213
+ | `cockpit manager clients` | `manager_clients` | `GET /api/manager/clients` |
214
+ | `cockpit manager dismiss` | `manager_dismiss` | `POST /api/manager/actions/[id]/review (or /timeline/[id]/review)` |
215
+ | `cockpit manager done` | `manager_done` | `POST /api/manager/actions/[id]/status` |
216
+ | `cockpit manager edit` | `manager_edit` | `PATCH /api/manager/actions/[id] (or /timeline/[id])` |
217
+ | `cockpit manager link` | `manager_link` | `POST /api/manager/actions/[id]/link` |
218
+ | `cockpit manager list` | `manager_list` | `GET /api/manager/clients/[clientId]/actions (or /timeline)` |
219
+ | `cockpit manager proposal` | `manager_proposal` | `POST /api/manager/actions/[id]/proposal (or /timeline/[id]/proposal)` |
220
+ | `cockpit manager queue` | `manager_queue` | `GET /api/manager/clients/[clientId]/review-queue` |
221
+ | `cockpit manager reopen` | `manager_reopen` | `POST /api/manager/actions/[id]/status` |
222
+ | `cockpit manager run` | `manager_run` | `GET /api/manager/runs/[runId]` |
223
+ | `cockpit manager show` | `manager_show` | `GET /api/manager/actions/[id] (or /timeline/[id])` |
224
+ | `cockpit manager unarchive` | `manager_unarchive` | `POST /api/manager/actions/[id]/unarchive (or /timeline/[id]/unarchive)` |
209
225
  | `cockpit memory chain` | `memory_chain` | `GET /api/memory/chain` |
210
226
  | `cockpit memory consolidation` | `memory_consolidation` | `GET /api/memory/consolidation` |
211
227
  | `cockpit memory forget` | `forget_memory` (`bli-memory`) | `POST /api/memory/forget` |
@@ -277,6 +293,7 @@ three tables below.
277
293
  | `cockpit team device doctor` | `team_device_doctor` | `POST /api/ambient/devices/[deviceId]/doctor` |
278
294
  | `cockpit team device list` | `team_device_list` | `GET /api/team/devices` |
279
295
  | `cockpit team device revoke` | `team_device_revoke` | `POST /api/ambient/devices/[deviceId]/revoke` |
296
+ | `cockpit team device scope` | `team_device_scope` | `POST /api/ambient/devices/[deviceId]/scope` |
280
297
  | `cockpit team github-login add` | `team_github_login_add` | `POST /api/team/members/[userId]/github-logins` |
281
298
  | `cockpit team github-login list` | `team_github_login_list` | `GET /api/team/members/[userId]/github-logins` |
282
299
  | `cockpit team github-login remove` | `team_github_login_remove` | `DELETE /api/team/members/[userId]/github-logins` |
@@ -304,7 +321,7 @@ A claim about the verb's nature, not a backlog.
304
321
  | `cockpit brief edit` | opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts) |
305
322
  | `cockpit cal add-ical` | carries a calendar's SECRET iCal ADDRESS — a permanent, unauthenticated, read-anything-on-that-calendar URL. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a calendar stays a thing a person does at a terminal (BLI-3709) |
306
323
  | `cockpit mail add-imap` | carries a Google app password. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a mailbox stays a thing a person does at a terminal (BLI-3708) |
307
- | `cockpit notes connect` | carries a notetaker credential: an API key (Fellow or Circleback), or for Wispr Flow a browser sign-in whose refresh token the CLI hands straight to Tower. The CLI reads a key from STDIN precisely so it never lands in an argument list, and a browser sign-in has no tool-call equivalent at all; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so connecting a notetaker stays a thing a person does at a terminal or in Tower's Settings (BLI-4383, BLI-12926) |
324
+ | `cockpit notes connect` | an API-key connect (Fellow or Circleback) carries a notetaker credential. The CLI reads the key from STDIN precisely so it never lands in an argument list, and an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so connecting a key stays a thing a person does at a terminal or in Tower's Settings (BLI-4383). Wispr Flow carries no credential through a call: its sign-in is a link the person clicks, started and watched over the same doors by the MCP tools notes_connect_wisprflow and notes_connect_status (BLI-17973) |
308
325
  | `cockpit notes webhook` | carries the VENDOR's webhook signing secret, the key an HMAC over every delivered payload is checked against. Exactly the ruling `notes connect` already carries, for the same credential shape: the CLI reads it from STDIN so it never lands in an argument list, and an MCP tool argument travels through a model's context window and whatever transcript store sits behind it (BLI-4394) |
309
326
 
310
327
  <!-- END GENERATED verb census -->
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The device-scope twin in the verb census (BLI-17978).
3
+ *
4
+ * `cockpit team device scope <device> notetaker_bot|none` and `team_device_scope` both ride
5
+ * `POST /api/ambient/devices/[deviceId]/scope`, the one door that gives the meeting bot's device the
6
+ * scope Tower's claim door requires (or takes it away). Split out of `verb-census.ts`, which sits at
7
+ * the file-size ratchet.
8
+ */
9
+ import type { McpTwin } from "./verb-census.js";
10
+ export declare const DEVICE_TWINS: Record<string, McpTwin>;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The device-scope twin in the verb census (BLI-17978).
3
+ *
4
+ * `cockpit team device scope <device> notetaker_bot|none` and `team_device_scope` both ride
5
+ * `POST /api/ambient/devices/[deviceId]/scope`, the one door that gives the meeting bot's device the
6
+ * scope Tower's claim door requires (or takes it away). Split out of `verb-census.ts`, which sits at
7
+ * the file-size ratchet.
8
+ */
9
+ export const DEVICE_TWINS = {
10
+ "team device-scope": { tool: "team_device_scope", door: "POST /api/ambient/devices/[deviceId]/scope" },
11
+ };
@@ -0,0 +1,7 @@
1
+ import { type AgentDoorResponse } from "./agent-door.js";
2
+ import { type ToolDeps } from "./tool-result.js";
3
+ /** The Manager's refusal in the person's words: label, finer label when named, then the sentence. */
4
+ export declare function managerFailureText(tool: string, response: AgentDoorResponse): string;
5
+ export declare function registerManagerTools(server: {
6
+ registerTool: (...args: never[]) => unknown;
7
+ }, deps: ToolDeps): void;
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Tower Manager over MCP (BLI-5006): the sixteen twins of `cockpit manager`.
3
+ *
4
+ * Plain words: the Manager keeps, per client, a timeline (meetings, documents,
5
+ * milestones, internal events) and a list of actions (to-dos with owners and a
6
+ * due date). The AI proposes items and changes; a person reviews them. These tools
7
+ * let an agent read both and do what a person does on the page: add, edit, tick off,
8
+ * link, approve or dismiss the AI's suggestion, archive. They ride the SAME
9
+ * `/api/manager/**` door the CLI calls, on this machine's device token; the door
10
+ * hands the Manager's own answer back unchanged.
11
+ *
12
+ * Every write on an existing item carries `version`, the item's `updatedAt` as last
13
+ * read, sent as `expectedUpdatedAt`: a stale one is refused (409 invalid_state,
14
+ * `refusal item_changed`) rather than written over someone else's change.
15
+ *
16
+ * Deciding the AI's suggestion (approve, dismiss, proposal) is not an outward act, so
17
+ * none of these needs `confirm`: it records the person whose key this is as the
18
+ * reviewer and takes the item out of the review queue unless a suggested change still waits on it. Nothing here reaches anyone.
19
+ *
20
+ * Refusals come in two shapes and are relayed in the person's own words
21
+ * (`managerFailureText`): the Manager's `{ error: <sentence>, reason, refusal? }` and
22
+ * the door's own `{ code, reason, message }`. Only these tools read the first shape;
23
+ * every other family's refusal text is untouched.
24
+ */
25
+ import { randomUUID } from "node:crypto";
26
+ import { z } from "zod";
27
+ import { callAgentDoor } from "./agent-door.js";
28
+ import { dataResult, doorFailureText, doorReason, errorResult, queryString, registrarFor, withSession, } from "./tool-result.js";
29
+ const KINDS = ["meeting", "document", "milestone", "internal"];
30
+ const ACTION_STATUSES = ["open", "done", "dropped", "closed", "all"];
31
+ const DEFAULT_LIMIT = 50;
32
+ const MAX_LIMIT = 200;
33
+ const CLIENT_ID = z.string().uuid().describe("The client's id, from manager_clients.");
34
+ const ITEM_ID = z.string().uuid().describe("The item's id, from manager_list, manager_queue or manager_show.");
35
+ const TIMELINE = z.boolean().optional()
36
+ .describe("true when the item is a timeline item (a meeting, document, milestone or internal event). Omit or false for an action (a to-do).");
37
+ const VERSION = z.string().min(1)
38
+ .describe("The item's updatedAt exactly as manager_list or manager_show returned it. If anyone changed the item since, the write is refused (item_changed): read it again and retry.");
39
+ const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date written YYYY-MM-DD");
40
+ const OWNERS = z.array(z.string().min(1).max(120)).max(20).optional()
41
+ .describe("Actions only: the owners, one person per entry. Leaving this out leaves the owners alone; an empty list clears them; a list replaces them.");
42
+ const OWNER_NAME = z.string().max(120).nullable().optional()
43
+ .describe("Timeline items only: the one owner's name. Leaving this out leaves the owner alone; null (or an empty string) clears it.");
44
+ /** The fields only one table has; naming one on the other table is refused here, never dropped silently. */
45
+ const ACTION_ONLY = ["owners", "due", "meeting_id", "status", "owner", "no_owner", "overdue"];
46
+ const TIMELINE_ONLY = ["kind", "date", "owner_name"];
47
+ /** The Manager's refusal in the person's words: label, finer label when named, then the sentence. */
48
+ export function managerFailureText(tool, response) {
49
+ if (response.transportError)
50
+ return doorFailureText(tool, response);
51
+ const body = response.body;
52
+ const str = (key) => (typeof body[key] === "string" && body[key] !== "" ? body[key] : null);
53
+ const sentence = str("message") ?? (typeof body["reason"] === "string" ? str("error") : null) ?? `Tower answered ${response.status}.`;
54
+ const refusal = str("refusal");
55
+ const reason = doorReason(body);
56
+ // A change with no answer may or may not have landed (an add says to retry with its request_id instead).
57
+ const unknown = (reason === "manager_timeout" || reason === "manager_unreachable" || response.status >= 500) && tool !== "manager_add" && !READ_TOOLS_HERE.has(tool);
58
+ const next = NEXT_STEP[refusal ?? ""] ?? (reason === "not_found" ? NEXT_STEP["not_found"] : unknown ? NEXT_STEP["no_answer"] : undefined);
59
+ return `Tower refused ${tool} (${doorReason(body)}${refusal ? `, refusal ${refusal}` : ""}): ${sentence}${next ? ` ${next}` : ""}`;
60
+ }
61
+ /** What to do next after a refusal, in tool terms, so every refusal ends with a way forward (the CLI says the same). */
62
+ const READ_TOOLS_HERE = new Set(["manager_clients", "manager_client", "manager_run", "manager_list", "manager_queue", "manager_show"]);
63
+ const NEXT_STEP = {
64
+ no_answer: "It may or may not have been saved: read the item with manager_show, and use the version it gives if you try again.",
65
+ not_found: "Check the id with manager_list; a timeline item needs timeline true, and done, reopen and link are for actions only.",
66
+ item_changed: "Read the item again with manager_show for its current version, then retry.",
67
+ item_archived: "Bring it back first with manager_unarchive (manager_list with archived true gives its version).",
68
+ item_dismissed: "Restore it first with manager_approve and restore true (manager_list with review dismissed gives its version).",
69
+ person_item_not_dismissable: "To delete an item a person added, use manager_archive.",
70
+ proposal_replaced: "Read the item with manager_show to see the suggested changes waiting and their ids.",
71
+ proposal_decided: "Read the item with manager_show for what is still waiting.",
72
+ timeline_not_live: "Link to a live timeline item of the same client (manager_list with timeline true).",
73
+ item_not_archived: "It is already in the lists; nothing to bring back.",
74
+ request_reused: "Add again without request_id to make a new item.",
75
+ };
76
+ /** Fields named for the wrong table: a refusal that says which, so the caller fixes the call. */
77
+ function wrongTable(tool, args, timeline) {
78
+ const misplaced = (timeline ? ACTION_ONLY : TIMELINE_ONLY).filter((key) => args[key] !== undefined);
79
+ if (misplaced.length === 0)
80
+ return null;
81
+ return errorResult(`Refused (wrong_table_field): ${tool} ${timeline ? "with timeline true" : "on an action"} does not take ${misplaced.join(", ")}`
82
+ + `${timeline ? " (those are for actions)" : " (those are for timeline items; pass timeline true)"}. Nothing was changed.`);
83
+ }
84
+ /** The item fields a caller named, in the door's own spelling. A field left out stays out: the door keeps its value. */
85
+ function itemFields(args) {
86
+ const wire = {
87
+ title: "title", detail: "detail", kind: "kind", due: "dueAt", date: "occurredAt",
88
+ owners: "owners", owner_name: "ownerName", meeting_id: "timelineItemId", source_url: "sourceUrl", source_note: "sourceNote",
89
+ };
90
+ const body = {};
91
+ for (const [key, name] of Object.entries(wire))
92
+ if (args[key] !== undefined)
93
+ body[name] = args[key];
94
+ return body;
95
+ }
96
+ /** The first `limit` rows of a list, with the cut named (a client with hundreds of items is not shipped whole). */
97
+ function page(rows, limit) {
98
+ const all = Array.isArray(rows) ? rows : [];
99
+ return { rows: all.slice(0, limit), more: all.length > limit };
100
+ }
101
+ const LIST_CAP = 500;
102
+ const QUEUE_CAP = 200;
103
+ /** What follows "Showing N of TOTAL" when rows are left out: the server's own cap when it cut, then the next step. */
104
+ function nextStep(serverCap, limit, narrow) {
105
+ const steps = [...(limit < MAX_LIMIT ? [serverCap ? `raise limit to ${MAX_LIMIT}` : "raise limit"] : []), ...(narrow ? ["narrow with filters"] : [])];
106
+ return `${serverCap ? ` (server cap ${serverCap})` : ""}${steps.length > 0 ? `; ${steps.join(" or ")}` : ""}.`;
107
+ }
108
+ /** The Manager writes nothing when an action already has the status asked for, so its version comes back as sent. */
109
+ const sameVersion = (answer, version) => answer.item?.updatedAt === version;
110
+ const titleOf = (answer) => {
111
+ const item = answer.item;
112
+ return typeof item?.title === "string" ? item.title : "that item";
113
+ };
114
+ export function registerManagerTools(server, deps) {
115
+ const register = registrarFor(server);
116
+ /** One door call, relayed: the data on success, the Manager's reason and sentence on a refusal. `suffix` is added when the answer was lost or the server failed (the write may or may not have happened). */
117
+ async function ask(tool, method, path, body, headline, extra = {}) {
118
+ return withSession(deps, async (session) => {
119
+ const response = await callAgentDoor(session, deps.fetchImpl, method, path, body);
120
+ if (!response.ok)
121
+ return errorResult(`${managerFailureText(tool, response)}${response.transportError || response.status >= 500 ? (extra.suffix ?? "") : ""}`);
122
+ const shown = extra.shape ? extra.shape(response.body) : response.body;
123
+ return dataResult(headline(shown), shown);
124
+ });
125
+ }
126
+ const door = (rest) => `/api/manager/${rest}`;
127
+ const table = (args) => (args.timeline === true ? "timeline" : "actions");
128
+ const itemPath = (args, tail = "") => door(`${table(args)}/${encodeURIComponent(String(args.item_id))}${tail}`);
129
+ const clientPath = (args, tail) => door(`clients/${encodeURIComponent(String(args.client_id))}/${tail}`);
130
+ const limitOf = (args) => (typeof args.limit === "number" ? args.limit : DEFAULT_LIMIT);
131
+ const LIMIT = z.number().int().min(1).max(MAX_LIMIT).optional().describe(`How many rows to return, 1 to ${MAX_LIMIT}. Default ${DEFAULT_LIMIT}; the count of every match is always given.`);
132
+ register("manager_clients", {
133
+ title: "List Tower Manager clients",
134
+ description: "The clients Tower Manager follows, each with its id (use it as client_id in the other manager tools), name, onboarding state and counts. Read-only. `cockpit manager clients`, same door.",
135
+ inputSchema: {},
136
+ }, async () => ask("manager_clients", "GET", door("clients"), undefined, (answer) => `${Array.isArray(answer.clients) ? answer.clients.length : 0} client(s).`));
137
+ register("manager_list", {
138
+ title: "List a client's actions or timeline",
139
+ description: "One client's actions (to-dos, default) or, with timeline true, its timeline items. Each row carries its id, title, owners, date, review state, any change the AI proposed and `updatedAt` (the `version` a write needs). "
140
+ + "Filters: needs_review (only items waiting for a person), search, archived (only archived items), review (dismissed or all: dismissed items are hidden by default), plus for actions status (open default, done, dropped, closed, all), owner (one person), no_owner, overdue; for timeline items kind. "
141
+ + `Returns the first ${DEFAULT_LIMIT} rows by default (limit up to ${MAX_LIMIT}); \`total\` is every match. Read-only. \`cockpit manager list\`, same door.`,
142
+ inputSchema: {
143
+ client_id: CLIENT_ID,
144
+ timeline: TIMELINE,
145
+ needs_review: z.boolean().optional().describe("true: only items waiting for a person (never reviewed, or carrying a proposed change)."),
146
+ status: z.enum(ACTION_STATUSES).optional().describe("Actions only: open (default), done, dropped, closed (done and dropped) or all."),
147
+ owner: z.string().min(1).max(120).optional().describe("Actions only: one owner's name; a shared action is under each of its owners."),
148
+ no_owner: z.boolean().optional().describe("Actions only: true for actions nobody owns. Not with owner."),
149
+ overdue: z.boolean().optional().describe("Actions only: true for open actions due before today."),
150
+ kind: z.enum(KINDS).optional().describe("Timeline items only: meeting, document, milestone or internal."),
151
+ archived: z.boolean().optional().describe("true: only archived items."),
152
+ review: z.enum(["dismissed", "all"]).optional().describe("Dismissed items are hidden by default: dismissed lists only those the person dismissed, all lists every item whatever its review state."),
153
+ search: z.string().min(1).max(200).optional().describe("Words to find in the title or detail."),
154
+ limit: LIMIT,
155
+ },
156
+ }, async (args) => {
157
+ const timeline = args.timeline === true;
158
+ const refused = wrongTable("manager_list", args, timeline);
159
+ if (refused)
160
+ return refused;
161
+ const query = new URLSearchParams();
162
+ if (typeof args.kind === "string")
163
+ query.set("kind", args.kind);
164
+ if (typeof args.status === "string")
165
+ query.set("status", args.status);
166
+ if (typeof args.owner === "string")
167
+ query.set("owner", args.owner);
168
+ if (args.no_owner === true)
169
+ query.set("noOwner", "true");
170
+ if (args.overdue === true)
171
+ query.set("overdue", "true");
172
+ if (args.needs_review === true)
173
+ query.set("needsReview", "true");
174
+ if (args.archived === true)
175
+ query.set("archived", "true");
176
+ if (args.review === "dismissed")
177
+ query.set("review", "rejected"); // Dismiss writes review status "rejected"
178
+ if (args.review === "all")
179
+ query.set("review", "all");
180
+ if (typeof args.search === "string")
181
+ query.set("q", args.search);
182
+ const limit = limitOf(args);
183
+ return ask("manager_list", "GET", clientPath(args, `${table(args)}${queryString(query)}`), undefined, (answer) => answer.shown === answer.total || typeof answer.total !== "number"
184
+ ? `${String(answer.shown)} of ${String(answer.total ?? answer.shown)} ${timeline ? "timeline item(s)" : "action(s)"}.`
185
+ : `Showing ${String(answer.shown)} of ${String(answer.total)}${nextStep(answer.truncated === true ? LIST_CAP : null, limit, true)}`, { shape: (answer) => {
186
+ const { rows, more } = page(answer.items, limit);
187
+ return { ...answer, items: rows, shown: rows.length, more_exist: more };
188
+ } });
189
+ });
190
+ register("manager_client", {
191
+ title: "Read one Manager client",
192
+ description: "One client in full: its name, Notion root, onboarding state, when it was last synced and whether a run holds it now, its latest read summed over its receipts, pages waiting and incomplete, and its newest brief rebuild. "
193
+ + "Read-only. `cockpit manager client`, same door.",
194
+ inputSchema: { client_id: CLIENT_ID },
195
+ }, async (args) => ask("manager_client", "GET", door(`clients/${encodeURIComponent(String(args.client_id))}`), undefined, (answer) => `${typeof answer.name === "string" ? answer.name : "That client"}.`));
196
+ register("manager_run", {
197
+ title: "Read one Manager run's receipt",
198
+ description: "One run's receipt: its kind (sync, extract, onboard, brief_rebuild), when it started and finished, why it ended (reason), and its counts (pages seen, changed, fetched, archived; operations applied, proposed, rejected; model and tokens). "
199
+ + "Read-only. `cockpit manager run`, same door.",
200
+ inputSchema: { run_id: z.string().uuid().describe("The run's id: `changedByRunId` on a manager_queue row, `runId` on a proposal in manager_show, or `lastBriefRebuild.runId` in manager_client.") },
201
+ }, async (args) => ask("manager_run", "GET", door(`runs/${encodeURIComponent(String(args.run_id))}`), undefined, (answer) => `Run ${typeof answer.kind === "string" ? answer.kind : ""} (${typeof answer.reason === "string" ? answer.reason : "no reason named"}), ${answer.finishedAt ? "finished" : "not finished"}.`));
202
+ register("manager_queue", {
203
+ title: "A client's review queue",
204
+ description: "Changes from syncs after onboarding that nobody has reviewed yet, for one client: what a later sync added, changed or proposed, timeline items and actions, each with why it is queued (added, changed, proposal). "
205
+ + "The onboarding's own items are not in it, so an empty queue does not mean everything is reviewed: for that, use manager_list with needs_review true and status all, and manager_list with timeline true and needs_review true. "
206
+ + "Work it with manager_approve, manager_dismiss and manager_proposal. Read-only. `cockpit manager queue`, same door.",
207
+ inputSchema: { client_id: CLIENT_ID, limit: LIMIT },
208
+ }, async (args) => {
209
+ const limit = limitOf(args);
210
+ return ask("manager_queue", "GET", clientPath(args, "review-queue"), undefined, (answer) => {
211
+ const total = (answer.total ?? {});
212
+ const shown = (answer.shown ?? {});
213
+ const [totalTl, totalAc] = [total.timeline ?? 0, total.actions ?? 0];
214
+ if ((shown.timeline ?? 0) >= totalTl && (shown.actions ?? 0) >= totalAc)
215
+ return `${totalTl} timeline item(s) and ${totalAc} action(s) in the queue.`;
216
+ return `Showing ${String(shown.timeline)} of ${totalTl} timeline item(s) and ${String(shown.actions)} of ${totalAc} action(s)${nextStep(answer.truncated === true ? QUEUE_CAP : null, limit, false)}`;
217
+ }, { shape: (answer) => {
218
+ const timeline = page(answer.timeline, limit);
219
+ const actions = page(answer.actions, limit);
220
+ return { ...answer, timeline: timeline.rows, actions: actions.rows, shown: { timeline: timeline.rows.length, actions: actions.rows.length }, more_exist: timeline.more || actions.more };
221
+ } });
222
+ });
223
+ register("manager_show", {
224
+ title: "Read one Manager item",
225
+ description: "One action (default) or, with timeline true, one timeline item in full: its fields, the sources it cites, every change the AI proposed (each with the `id` manager_proposal needs), "
226
+ + "and what it links to. `updatedAt` is the `version` the next write on it needs. Read-only. `cockpit manager show`, same door.",
227
+ inputSchema: { item_id: ITEM_ID, timeline: TIMELINE },
228
+ }, async (args) => ask("manager_show", "GET", itemPath(args), undefined, (answer) => `${titleOf(answer)}.`));
229
+ register("manager_add", {
230
+ title: "Add an action or timeline item",
231
+ description: "Adds one action (default: title, optional detail, due date, owners, and meeting_id to link it to a timeline item) or, with timeline true, one timeline item (kind and title are required; date is optional: "
232
+ + "without one it lands in Needs a date). Dates are YYYY-MM-DD. The item is the person's own and needs no review; source_url and source_note say where it came from. "
233
+ + "The answer (and a lost-answer or server-error refusal) carries `request_id`: add again with the same request_id and no duplicate is made; pass your own request_id (a uuid) up front to be able to retry. "
234
+ + "A request_id already used by another person or client is refused (request_reused). `cockpit manager add`, same door.",
235
+ inputSchema: {
236
+ client_id: CLIENT_ID,
237
+ timeline: TIMELINE,
238
+ kind: z.enum(KINDS).optional().describe("Timeline items only, required there: meeting, document, milestone or internal."),
239
+ title: z.string().min(1).max(300).describe("The item's title."),
240
+ detail: z.string().max(4000).optional().describe("A short paragraph about the item."),
241
+ due: DATE.optional().describe("Actions only: the due date, YYYY-MM-DD."),
242
+ date: DATE.optional().describe("Timeline items only: when it happened or will, YYYY-MM-DD. Leave out for undated."),
243
+ owners: OWNERS,
244
+ owner_name: OWNER_NAME,
245
+ meeting_id: z.string().uuid().optional().describe("Actions only: the timeline item (id from manager_list with timeline true) to link the action to."),
246
+ source_url: z.string().url().max(2000).regex(/^https?:\/\//i).optional().describe("Where it came from: an http(s) link, as the web form's Source. manager_show lists it."),
247
+ source_note: z.string().min(1).max(1000).optional().describe("Where it came from, in words (at most 1,000 characters). manager_show lists it."),
248
+ request_id: z.string().uuid().optional().describe("A uuid naming this add, so a retry returns the same item. Default: a fresh one."),
249
+ },
250
+ }, async (args) => {
251
+ const timeline = args.timeline === true;
252
+ const refused = wrongTable("manager_add", args, timeline);
253
+ if (refused)
254
+ return refused;
255
+ const requestId = typeof args.request_id === "string" ? args.request_id : randomUUID();
256
+ return ask("manager_add", "POST", clientPath(args, table(args)), { ...itemFields(args), requestId }, (answer) => `Added ${titleOf(answer)}. request_id ${String(answer.request_id)}: retry with it if unsure it was saved.`, { shape: (answer) => ({ ...answer, request_id: requestId }), suffix: ` If unsure whether it was saved, call manager_add again with request_id ${requestId}: no duplicate is made.` });
257
+ });
258
+ register("manager_edit", {
259
+ title: "Edit an action or timeline item",
260
+ description: "Changes the fields you name on an action (default) or, with timeline true, a timeline item; a field you leave out keeps its value. `version` is required. Dates are YYYY-MM-DD; null clears a date. "
261
+ + "Actions: owners (leave out to leave the owners alone, [] to clear, a list to replace). Timeline items: kind, date, owner_name. `cockpit manager edit`, same door.",
262
+ inputSchema: {
263
+ item_id: ITEM_ID,
264
+ timeline: TIMELINE,
265
+ version: VERSION,
266
+ title: z.string().min(1).max(300).optional().describe("The new title."),
267
+ detail: z.string().max(4000).optional().describe("The new detail; an empty string clears it."),
268
+ kind: z.enum(KINDS).optional().describe("Timeline items only: the new kind."),
269
+ due: DATE.nullable().optional().describe("Actions only: the new due date, YYYY-MM-DD, or null for none."),
270
+ date: DATE.nullable().optional().describe("Timeline items only: the new date, YYYY-MM-DD, or null for undated."),
271
+ owners: OWNERS,
272
+ owner_name: OWNER_NAME,
273
+ },
274
+ }, async (args) => {
275
+ const refused = wrongTable("manager_edit", args, args.timeline === true);
276
+ if (refused)
277
+ return refused;
278
+ return ask("manager_edit", "PATCH", itemPath(args), { ...itemFields(args), expectedUpdatedAt: args.version }, (answer) => `Saved ${titleOf(answer)}.`);
279
+ });
280
+ const ACTION_VERSION = { item_id: z.string().uuid().describe("The action's id, from manager_list."), version: VERSION };
281
+ register("manager_done", {
282
+ title: "Mark an action done",
283
+ description: "Ticks an action off (status done). Actions only. `version` is required. manager_reopen undoes it. `cockpit manager done`, same door.",
284
+ inputSchema: ACTION_VERSION,
285
+ }, async (args) => ask("manager_done", "POST", itemPath({ ...args, timeline: false }, "/status"), { status: "done", expectedUpdatedAt: args.version }, (answer) => (sameVersion(answer, args.version) ? `Already done; nothing changed: ${titleOf(answer)}.` : `Marked done: ${titleOf(answer)}.`)));
286
+ register("manager_reopen", {
287
+ title: "Reopen an action",
288
+ description: "Puts a done action back to open. Actions only. `version` is required. `cockpit manager reopen`, same door.",
289
+ inputSchema: ACTION_VERSION,
290
+ }, async (args) => ask("manager_reopen", "POST", itemPath({ ...args, timeline: false }, "/status"), { status: "open", expectedUpdatedAt: args.version }, (answer) => (sameVersion(answer, args.version) ? `Already open; nothing changed: ${titleOf(answer)}.` : `Reopened: ${titleOf(answer)}.`)));
291
+ register("manager_link", {
292
+ title: "Link an action to a timeline item",
293
+ description: "Links an action to a timeline item (a meeting, say) by meeting_id, or unlinks it when meeting_id is null. Actions only. `version` is the action's. `cockpit manager link`, same door.",
294
+ inputSchema: {
295
+ ...ACTION_VERSION,
296
+ meeting_id: z.string().uuid().nullable().describe("The timeline item's id (manager_list with timeline true), or null to unlink."),
297
+ },
298
+ }, async (args) => ask("manager_link", "POST", itemPath({ ...args, timeline: false }, "/link"), { timelineItemId: args.meeting_id, expectedUpdatedAt: args.version }, (answer) => `${args.meeting_id === null ? "Unlinked" : "Linked"}: ${titleOf(answer)}.`));
299
+ const AS_YOU = "This decides the AI's suggestion as the person whose key this is: their name is recorded as the reviewer, and the item leaves the review queue unless a suggested change still waits on it. It reaches nobody, so no confirm is needed.";
300
+ register("manager_approve", {
301
+ title: "Approve an AI-added item",
302
+ description: `Approves an item the AI added or changed: it stays, marked reviewed. ${AS_YOU} For an item carrying a proposed change use manager_proposal. `
303
+ + "Approving a DISMISSED item brings it back into the lists (the web's Restore button), so the item is read first: a dismissed one is refused unless restore is true, and restore on an item that is not dismissed is refused too. Nothing is written on a refusal. "
304
+ + "`version` is required. `cockpit manager approve` (with `--restore`), same door.",
305
+ inputSchema: {
306
+ item_id: ITEM_ID,
307
+ timeline: TIMELINE,
308
+ version: VERSION,
309
+ restore: z.boolean().optional().describe("true to bring a DISMISSED item back into the lists, marked reviewed by the person whose key this is. Only for a dismissed item; leave it out to approve an item that is not dismissed."),
310
+ },
311
+ }, async (args) => withSession(deps, async (session) => {
312
+ const read = await callAgentDoor(session, deps.fetchImpl, "GET", itemPath(args), undefined);
313
+ if (!read.ok)
314
+ return errorResult(managerFailureText("manager_show", read));
315
+ const dismissed = read.body.item?.reviewStatus === "rejected";
316
+ const restore = args.restore === true;
317
+ if (dismissed && !restore) {
318
+ return errorResult("Refused (restore_needed): This item is dismissed. Approving it brings it back into the lists, marked reviewed by you. To do that, call manager_approve again with restore true. Nothing was changed.");
319
+ }
320
+ if (!dismissed && restore) {
321
+ return errorResult("Refused (not_dismissed): restore is only for a dismissed item; this one is not dismissed. Call manager_approve without restore. Nothing was changed.");
322
+ }
323
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", itemPath(args, "/review"), { decision: "accept", expectedUpdatedAt: args.version });
324
+ if (!response.ok)
325
+ return errorResult(managerFailureText("manager_approve", response));
326
+ return dataResult(restore ? `Restored: ${titleOf(response.body)}, back in the lists and marked reviewed by you.` : `Approved ${titleOf(response.body)}.`, response.body);
327
+ }));
328
+ register("manager_dismiss", {
329
+ title: "Dismiss an AI-added item",
330
+ description: `Dismisses an item the AI added that does not belong: it stops showing in the lists. ${AS_YOU} For a proposed change use manager_proposal with decision dismiss. `
331
+ + "`version` is required. `cockpit manager dismiss`, same door.",
332
+ inputSchema: { item_id: ITEM_ID, timeline: TIMELINE, version: VERSION },
333
+ }, async (args) => ask("manager_dismiss", "POST", itemPath(args, "/review"), { decision: "dismiss", expectedUpdatedAt: args.version }, (answer) => `Dismissed ${titleOf(answer)}.`));
334
+ register("manager_proposal", {
335
+ title: "Decide one AI-proposed change",
336
+ description: `Decides one change the AI proposed to an item a person owns: accept applies it, dismiss keeps the item as it is and remembers the refusal, add_new makes the proposal an item of its own (update and duplicate proposals only; a close proposal is refused). ${AS_YOU} `
337
+ + "`proposal_id` is the `id` of an entry in the item's proposedChanges (manager_show); a newer run may have replaced it, then the decision is refused. `version` is required. `cockpit manager proposal`, same door.",
338
+ inputSchema: {
339
+ item_id: ITEM_ID,
340
+ timeline: TIMELINE,
341
+ version: VERSION,
342
+ proposal_id: z.string().min(1).max(64).describe("The proposal's id, from the item's proposedChanges in manager_show."),
343
+ decision: z.enum(["accept", "dismiss", "add_new"]).describe("accept, dismiss, or add_new (make it a new item)."),
344
+ },
345
+ }, async (args) => ask("manager_proposal", "POST", itemPath(args, "/proposal"), {
346
+ decision: args.decision === "add_new" ? "add_as_new" : args.decision,
347
+ proposalId: args.proposal_id,
348
+ expectedUpdatedAt: args.version,
349
+ }, (answer) => {
350
+ const added = answer.added;
351
+ return `Decided (${String(args.decision)}) on ${titleOf(answer)}.${typeof added?.title === "string" ? ` Added as a new item: ${added.title}.` : ""}`;
352
+ }));
353
+ register("manager_archive", {
354
+ title: "Archive an item",
355
+ description: "Archives an action (default) or timeline item (timeline true): it leaves the lists but is kept, and manager_unarchive puts it back as it was (list archived ones with manager_list archived true). `version` is required. `cockpit manager archive`, same door.",
356
+ inputSchema: { item_id: ITEM_ID, timeline: TIMELINE, version: VERSION },
357
+ }, async (args) => ask("manager_archive", "POST", itemPath(args, "/archive"), { expectedUpdatedAt: args.version }, (answer) => `Archived ${titleOf(answer)}.`));
358
+ register("manager_unarchive", {
359
+ title: "Unarchive an item",
360
+ description: "Puts an archived action (default) or timeline item (timeline true) back as it was. `version` is the archived item's updatedAt. `cockpit manager unarchive`, same door.",
361
+ inputSchema: { item_id: ITEM_ID, timeline: TIMELINE, version: VERSION },
362
+ }, async (args) => ask("manager_unarchive", "POST", itemPath(args, "/unarchive"), { expectedUpdatedAt: args.version }, (answer) => `Unarchived ${titleOf(answer)}.`));
363
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The Tower Manager's twins in the verb census (BLI-5006).
3
+ *
4
+ * `cockpit manager <verb>` and the `manager_*` tools both ride `/api/manager/**`,
5
+ * which hands the Manager's own answer back unchanged. A verb that works on both
6
+ * tables (an action, or a timeline item with `--timeline`) names both doors.
7
+ * Split out of `verb-census.ts`, which sits at the file-size ratchet.
8
+ */
9
+ import type { McpTwin } from "./verb-census.js";
10
+ export declare const MANAGER_TWINS: Record<string, McpTwin>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The Tower Manager's twins in the verb census (BLI-5006).
3
+ *
4
+ * `cockpit manager <verb>` and the `manager_*` tools both ride `/api/manager/**`,
5
+ * which hands the Manager's own answer back unchanged. A verb that works on both
6
+ * tables (an action, or a timeline item with `--timeline`) names both doors.
7
+ * Split out of `verb-census.ts`, which sits at the file-size ratchet.
8
+ */
9
+ export const MANAGER_TWINS = {
10
+ "manager clients": { tool: "manager_clients", door: "GET /api/manager/clients" },
11
+ "manager client": { tool: "manager_client", door: "GET /api/manager/clients/[clientId]" },
12
+ "manager run": { tool: "manager_run", door: "GET /api/manager/runs/[runId]" },
13
+ "manager list": { tool: "manager_list", door: "GET /api/manager/clients/[clientId]/actions (or /timeline)" },
14
+ "manager queue": { tool: "manager_queue", door: "GET /api/manager/clients/[clientId]/review-queue" },
15
+ "manager show": { tool: "manager_show", door: "GET /api/manager/actions/[id] (or /timeline/[id])" },
16
+ "manager add": { tool: "manager_add", door: "POST /api/manager/clients/[clientId]/actions (or /timeline)" },
17
+ "manager edit": { tool: "manager_edit", door: "PATCH /api/manager/actions/[id] (or /timeline/[id])" },
18
+ "manager done": { tool: "manager_done", door: "POST /api/manager/actions/[id]/status" },
19
+ "manager reopen": { tool: "manager_reopen", door: "POST /api/manager/actions/[id]/status" },
20
+ "manager link": { tool: "manager_link", door: "POST /api/manager/actions/[id]/link" },
21
+ "manager approve": { tool: "manager_approve", door: "POST /api/manager/actions/[id]/review (or /timeline/[id]/review)" },
22
+ "manager dismiss": { tool: "manager_dismiss", door: "POST /api/manager/actions/[id]/review (or /timeline/[id]/review)" },
23
+ "manager proposal": { tool: "manager_proposal", door: "POST /api/manager/actions/[id]/proposal (or /timeline/[id]/proposal)" },
24
+ "manager archive": { tool: "manager_archive", door: "POST /api/manager/actions/[id]/archive (or /timeline/[id]/archive)" },
25
+ "manager unarchive": { tool: "manager_unarchive", door: "POST /api/manager/actions/[id]/unarchive (or /timeline/[id]/unarchive)" },
26
+ };
@@ -5,13 +5,18 @@
5
5
  * authenticated with this machine's collector device token.
6
6
  *
7
7
  * ONE DELIBERATE ASYMMETRY WITH THE CLI, recorded in `verb-census.ts`:
8
- * **`cockpit notes connect` has NO MCP twin.** It carries a notetaker API key,
9
- * and the CLI takes that on stdin precisely so it never lands in an argument
10
- * list. An MCP tool argument IS an argument list: it travels through a model's
11
- * context window and whatever transcript store sits behind it. That is the
12
- * same ruling `mail add-imap` and `cal add-ical` already carry, for the same
13
- * credential shape, and connecting a notetaker stays a thing a person does at
14
- * a terminal or in Settings.
8
+ * **an API-key `cockpit notes connect` has NO MCP twin.** It carries a
9
+ * notetaker API key, and the CLI takes that on stdin precisely so it never
10
+ * lands in an argument list. An MCP tool argument IS an argument list: it
11
+ * travels through a model's context window and whatever transcript store sits
12
+ * behind it. That is the same ruling `mail add-imap` and `cal add-ical`
13
+ * already carry, for the same credential shape.
14
+ *
15
+ * Wispr Flow is the exception that proves it (BLI-17973): its sign-in carries
16
+ * no credential through a tool call. `notes_connect_wisprflow` starts it over
17
+ * the same `/api/notes/accounts/wisprflow/start` door the Settings button and
18
+ * `cockpit notes connect --provider wisprflow` call and returns a link for the
19
+ * PERSON to click; `notes_connect_status` polls the same status door.
15
20
  *
16
21
  * Same session discipline as `mail-tools.ts`: the session is loaded fresh per
17
22
  * call, so a machine with no `cockpit login` pairing still serves this
@@ -5,13 +5,18 @@
5
5
  * authenticated with this machine's collector device token.
6
6
  *
7
7
  * ONE DELIBERATE ASYMMETRY WITH THE CLI, recorded in `verb-census.ts`:
8
- * **`cockpit notes connect` has NO MCP twin.** It carries a notetaker API key,
9
- * and the CLI takes that on stdin precisely so it never lands in an argument
10
- * list. An MCP tool argument IS an argument list: it travels through a model's
11
- * context window and whatever transcript store sits behind it. That is the
12
- * same ruling `mail add-imap` and `cal add-ical` already carry, for the same
13
- * credential shape, and connecting a notetaker stays a thing a person does at
14
- * a terminal or in Settings.
8
+ * **an API-key `cockpit notes connect` has NO MCP twin.** It carries a
9
+ * notetaker API key, and the CLI takes that on stdin precisely so it never
10
+ * lands in an argument list. An MCP tool argument IS an argument list: it
11
+ * travels through a model's context window and whatever transcript store sits
12
+ * behind it. That is the same ruling `mail add-imap` and `cal add-ical`
13
+ * already carry, for the same credential shape.
14
+ *
15
+ * Wispr Flow is the exception that proves it (BLI-17973): its sign-in carries
16
+ * no credential through a tool call. `notes_connect_wisprflow` starts it over
17
+ * the same `/api/notes/accounts/wisprflow/start` door the Settings button and
18
+ * `cockpit notes connect --provider wisprflow` call and returns a link for the
19
+ * PERSON to click; `notes_connect_status` polls the same status door.
15
20
  *
16
21
  * Same session discipline as `mail-tools.ts`: the session is loaded fresh per
17
22
  * call, so a machine with no `cockpit login` pairing still serves this
@@ -64,9 +69,9 @@ export function registerNotesAccountTools(server, deps) {
64
69
  description: "Every notetaker account (Fellow, Circleback or Wispr Flow; Otter and Granola are named but not connectable) "
65
70
  + "connected to Tower that you may see: name, provider, workspace, health and when it was last read, plus the feeds the organization reads for "
66
71
  + "everyone (Granola, through its own key). Start here when you need an account id. ALSO answers `coverage`: for every member of the team, which notetakers feed "
67
- + "their notes, and which members have none (BLI-12926). Connecting one is NOT available here on purpose — it "
72
+ + "their notes, and which members have none (BLI-12926). Connecting a Fellow or Circleback key is NOT available here on purpose — it "
68
73
  + "carries a credential, so it is done at a terminal with `cockpit notes connect --provider fellow --workspace "
69
- + "<subdomain> --key-stdin` (or `--provider wisprflow`, which signs in through a browser) or in Tower's Settings.",
74
+ + "<subdomain> --key-stdin` or in Tower's Settings. Wispr Flow signs in with a link instead: notes_connect_wisprflow.",
70
75
  inputSchema: {},
71
76
  }, async () => withSession(deps, async (session) => {
72
77
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/notes/accounts");
@@ -155,10 +160,40 @@ export function registerNotesAccountTools(server, deps) {
155
160
  accounts,
156
161
  });
157
162
  }));
163
+ register("notes_connect_wisprflow", {
164
+ title: "Start connecting Wispr Flow",
165
+ description: "Starts signing in to Wispr Flow, the notetaker with no API key. Answers `authorize_url`: give it to the person as a link to click "
166
+ + "(they approve on Wispr Flow's page, signed in with the Wispr account that holds their notes, using Google, Apple, Microsoft or company single sign-on) "
167
+ + "and `attempt_id`, then call notes_connect_status with it to see whether it connected. It is the same door the Settings button and "
168
+ + "`cockpit notes connect --provider wisprflow` call. Carries no credential: the link is one-time and the verifier stays on Tower's server. "
169
+ + "Refuses with account_already_connected when Wispr Flow is already connected (detach it first).",
170
+ inputSchema: {},
171
+ }, async () => withSession(deps, async (session) => {
172
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/accounts/wisprflow/start", {});
173
+ if (!response.ok)
174
+ return errorResult(doorFailureText("notes_connect_wisprflow", response));
175
+ const url = String(response.body.authorize_url ?? "");
176
+ return textResult(`Open this link in a browser and approve on Wispr Flow's page, then check with notes_connect_status (attempt_id ${String(response.body.attempt_id)}), `
177
+ + `expires ${String(response.body.expires_at)}:\n${url}`, response.body);
178
+ }));
179
+ register("notes_connect_status", {
180
+ title: "Check a Wispr Flow sign-in",
181
+ description: "Where a Wispr Flow sign-in stands: pending (still waiting for the click), connected (with the account id), failed (with a reason label) or expired. "
182
+ + "Pass the attempt_id notes_connect_wisprflow returned. Only the person who started the attempt can read it.",
183
+ inputSchema: { attempt_id: z.string().uuid().describe("The attempt_id notes_connect_wisprflow returned.") },
184
+ }, async (args) => withSession(deps, async (session) => {
185
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/notes/accounts/wisprflow/status?attempt_id=${encodeURIComponent(String(args.attempt_id))}`);
186
+ if (!response.ok)
187
+ return errorResult(doorFailureText("notes_connect_status", response));
188
+ const reason = response.body.reason ? ` (${String(response.body.reason)})` : "";
189
+ return textResult(`Wispr Flow sign-in ${String(response.body.status)}${reason}.`, response.body);
190
+ }));
158
191
  register("notes_sync", {
159
192
  title: "Read a notetaker now",
160
193
  description: "Reads the meetings this notetaker has recorded since the last pass and files them as meeting notes. "
161
- + "Answers with the run's own counts and reason label, which is exactly what the ledger row says. "
194
+ + "One call is a bounded pass (under a minute): it answers with the run id, the run's own counts and reason label "
195
+ + "(exactly what the ledger row says), and `more`. While `more` is true, call notes_sync again; each call resumes "
196
+ + "from the saved cursor, and `done` means the account is caught up. "
162
197
  + "The half-hourly cron does this unasked; this is the button for when you do not want to wait.",
163
198
  inputSchema: { account_id: z.string().uuid().describe("A notetaker account id (notes_accounts).") },
164
199
  }, async (args) => withSession(deps, async (session) => {
@@ -168,6 +203,10 @@ export function registerNotesAccountTools(server, deps) {
168
203
  const run = response.body.run;
169
204
  return textResult(run
170
205
  ? `${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped. ${run.detail}`
206
+ + (run.runId ? ` Run ${run.runId}.` : "")
207
+ + (run.ok && run.more
208
+ ? ` More is waiting${typeof run.remaining === "number" ? ` (${run.remaining})` : ""}: call notes_sync again to continue from the saved cursor.`
209
+ : run.ok ? " Caught up." : "")
171
210
  : "Tower answered without a run.", response.body);
172
211
  }));
173
212
  register("notes_detach", {
package/dist/server.js CHANGED
@@ -43,6 +43,7 @@ import { registerUsageTools } from "./usage-tools.js";
43
43
  import { registerLogsTools } from "./logs-tools.js";
44
44
  import { registerBrainTools } from "./brain-tools.js";
45
45
  import { registerApprovalsTools } from "./approvals-tools.js";
46
+ import { registerManagerTools } from "./manager-tools.js";
46
47
  import { registerImageTools } from "./image-tools.js";
47
48
  import { registerSourcesTools } from "./sources-tools.js";
48
49
  import { TOWER_SERVER_ICONS } from "./server-icons.js";
@@ -526,6 +527,8 @@ export function createServer(deps, options = {}) {
526
527
  registerBrainTools(server, { fetchImpl: deps.fetchImpl });
527
528
  // BLI-16945: the approvals inbox, over the same /api/approvals doors the page and `cockpit approvals` call.
528
529
  registerApprovalsTools(server, { fetchImpl: deps.fetchImpl });
530
+ // BLI-5006: Tower Manager's clients, actions and timeline, over the same /api/manager door `cockpit manager` calls.
531
+ registerManagerTools(server, { fetchImpl: deps.fetchImpl });
529
532
  // BLI-16977: image descriptions, over the same /api/ambient/images door `cockpit image` calls.
530
533
  registerImageTools(server, { fetchImpl: deps.fetchImpl });
531
534
  return server;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `team_invite` / `team_role` / `team_device_revoke` MCP tools (BLI-3756 batch
2
+ * `team_invite` / `team_role` / `team_device_revoke` (and `team_device_scope`, BLI-17978) MCP tools (BLI-3756 batch
3
3
  * 2) — the three things an admin does to the team, over the same `/api/team/*`
4
4
  * and `/api/ambient/devices/*` doors `cockpit team` calls with the same device
5
5
  * token.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `team_invite` / `team_role` / `team_device_revoke` MCP tools (BLI-3756 batch
2
+ * `team_invite` / `team_role` / `team_device_revoke` (and `team_device_scope`, BLI-17978) MCP tools (BLI-3756 batch
3
3
  * 2) — the three things an admin does to the team, over the same `/api/team/*`
4
4
  * and `/api/ambient/devices/*` doors `cockpit team` calls with the same device
5
5
  * token.
@@ -167,6 +167,28 @@ export function registerTeamWriteTools(server, deps) {
167
167
  return errorResult(doorFailureText("team_device_doctor", response));
168
168
  return textResult(`Doctor requested for ${String(device.device_name ?? device.device_id)}. Waiting for its next check-in.`, response.body);
169
169
  }));
170
+ register("team_device_scope", {
171
+ title: "Give the meeting bot's device its scope, or take it away",
172
+ description: "Grants (`notetaker_bot`) or revokes (`none`) a device's service scope, the same door as `cockpit team device scope`. "
173
+ + "Without `notetaker_bot` Tower refuses every meeting claim from the bot (bot_scope_required). Super admin only, decided on the server.",
174
+ inputSchema: {
175
+ device: z.string().min(1).max(200).describe("A device id or one exact device name."),
176
+ scope: z.enum(["notetaker_bot", "none"]).describe("`notetaker_bot` to grant, `none` to revoke."),
177
+ },
178
+ }, async (args) => withSession(deps, async (session) => {
179
+ const listed = await listDevices(deps, session);
180
+ if (!listed.ok)
181
+ return errorResult(listed.text);
182
+ const device = findDevice(listed.devices, String(args.device ?? ""));
183
+ if (!device?.device_id)
184
+ return errorResult("No unique device matches. Call team_device_list to see ids and names. Nothing was changed.");
185
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/ambient/devices/${encodeURIComponent(device.device_id)}/scope`, { scope: String(args.scope ?? "") }, WRITE_DEADLINE_MS);
186
+ if (!response.ok)
187
+ return errorResult(doorFailureText("team_device_scope", response));
188
+ const name = String(device.device_name ?? device.device_id);
189
+ return textResult(response.body.changed === false ? `${name} already had that scope. Nothing changed.`
190
+ : args.scope === "none" ? `Took the service scope away from ${name}.` : `${name} now holds the notetaker_bot scope.`, response.body);
191
+ }));
170
192
  register("team_device_revoke", {
171
193
  title: "End a collector device's pairing",
172
194
  description: "Revokes one paired machine's token — `cockpit team device revoke`, same door. Irreversible for that token "
@@ -10,7 +10,7 @@
10
10
  * from `src/` under Node's type stripping, so it may import nothing.
11
11
  */
12
12
  /** Tools that change nothing. The smoke calls every one. */
13
- export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_board", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "rename_pending", "github_pr", "setup_read", "speed_read", "speed_seen", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "glossary_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_recordings", "notes_recording", "notes_sync_status", "notes_granola_status", "notes_bot_upcoming", "ops_status", "slack_coverage", "slack_connect", "slack_disconnect", "slack_read", "memory_map", "memory_rules", "memory_knowledge", "memory_profile", "memory_consolidation", "memory_chain", "settings_show", "settings_list", "model_show", "team_members", "team_github_login_add", "team_github_login_remove", "team_github_login_list", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check", "logs_digest", "brain_status", "approvals_list", "approvals_show", "approvals_rules", "image_show"];
13
+ export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_board", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "rename_pending", "github_pr", "setup_read", "speed_read", "speed_seen", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "glossary_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_connect_status", "notes_recordings", "notes_recording", "notes_sync_status", "notes_granola_status", "notes_bot_upcoming", "ops_status", "slack_coverage", "slack_connect", "slack_disconnect", "slack_read", "memory_map", "memory_rules", "memory_knowledge", "memory_profile", "memory_consolidation", "memory_chain", "settings_show", "settings_list", "model_show", "team_members", "team_github_login_add", "team_github_login_remove", "team_github_login_list", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check", "logs_digest", "brain_status", "approvals_list", "approvals_show", "approvals_rules", "manager_clients", "manager_client", "manager_run", "manager_list", "manager_queue", "manager_show", "image_show"];
14
14
  /**
15
15
  * Tools that write, but answer a dry run that writes nothing. The smoke calls
16
16
  * each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
@@ -21,7 +21,7 @@ export declare const DRY_RUN_TOOLS: readonly ["ops_recompile", "memory_requeue"]
21
21
  * candidates, share calendars. Never called by the smoke; covered by the unit
22
22
  * suites (`write-tools.test.ts` and each family's own).
23
23
  */
24
- export declare const WRITE_TOOLS: readonly ["image_describe", "approvals_create", "approvals_approve", "approvals_edit", "approvals_reject", "approvals_rules_create", "approvals_rules_revoke", "logs_key_mint", "brain_retry", "emit_event", "docs_create", "docs_update", "docs_delete", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_draft", "mail_rename", "mail_enable", "mail_disable", "mail_default", "mail_remove", "cal_change", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "glossary_add", "glossary_remove", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_grant", "team_revoke", "team_device_revoke", "team_device_doctor"];
24
+ export declare const WRITE_TOOLS: readonly ["image_describe", "approvals_create", "approvals_approve", "approvals_edit", "approvals_reject", "approvals_rules_create", "approvals_rules_revoke", "manager_add", "manager_edit", "manager_done", "manager_reopen", "manager_link", "manager_approve", "manager_dismiss", "manager_proposal", "manager_archive", "manager_unarchive", "logs_key_mint", "brain_retry", "emit_event", "docs_create", "docs_update", "docs_delete", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_draft", "mail_rename", "mail_enable", "mail_disable", "mail_default", "mail_remove", "cal_change", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "glossary_add", "glossary_remove", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_connect_wisprflow", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_grant", "team_revoke", "team_device_revoke", "team_device_doctor", "team_device_scope"];
25
25
  /** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
26
26
  export declare const MODEL_SPENDING_TOOLS: readonly ["jarvis_ask", "jarvis_check"];
27
27
  /**
@@ -67,6 +67,8 @@ export const READ_TOOLS = [
67
67
  "notes_shelves",
68
68
  "notes_place",
69
69
  "notes_accounts",
70
+ // BLI-17973: where a Wispr Flow sign-in stands (its start is a write, below).
71
+ "notes_connect_status",
70
72
  // BLI-12926: the alternates of a meeting several notetakers recorded.
71
73
  "notes_recordings",
72
74
  "notes_recording",
@@ -105,6 +107,13 @@ export const READ_TOOLS = [
105
107
  "approvals_list",
106
108
  "approvals_show",
107
109
  "approvals_rules",
110
+ // BLI-5006: Tower Manager's clients, lists, review queue and one item. Reads only.
111
+ "manager_clients",
112
+ "manager_client",
113
+ "manager_run",
114
+ "manager_list",
115
+ "manager_queue",
116
+ "manager_show",
108
117
  // BLI-16977: a stored image description, by sha256. Reads only.
109
118
  "image_show",
110
119
  ];
@@ -128,6 +137,17 @@ export const WRITE_TOOLS = [
128
137
  "approvals_reject",
129
138
  "approvals_rules_create",
130
139
  "approvals_rules_revoke",
140
+ // BLI-5006: Tower Manager's writes. Archive is undone by unarchive, so none is destructive.
141
+ "manager_add",
142
+ "manager_edit",
143
+ "manager_done",
144
+ "manager_reopen",
145
+ "manager_link",
146
+ "manager_approve",
147
+ "manager_dismiss",
148
+ "manager_proposal",
149
+ "manager_archive",
150
+ "manager_unarchive",
131
151
  "logs_key_mint",
132
152
  "brain_retry",
133
153
  "emit_event",
@@ -177,6 +197,8 @@ export const WRITE_TOOLS = [
177
197
  "notes_share",
178
198
  "notes_unshare",
179
199
  "notes_move",
200
+ // BLI-17973: registers a client at Wispr and records an attempt, so never smoked.
201
+ "notes_connect_wisprflow",
180
202
  "notes_sync",
181
203
  "notes_detach",
182
204
  "memory_experience",
@@ -196,6 +218,7 @@ export const WRITE_TOOLS = [
196
218
  "team_revoke",
197
219
  "team_device_revoke",
198
220
  "team_device_doctor",
221
+ "team_device_scope",
199
222
  ];
200
223
  /** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
201
224
  export const MODEL_SPENDING_TOOLS = ["jarvis_ask", "jarvis_check"];
@@ -39,6 +39,7 @@ export const TYPED_SPELLING = {
39
39
  "team device-list": "team device list",
40
40
  "team device-revoke": "team device revoke",
41
41
  "team device-doctor": "team device doctor",
42
+ "team device-scope": "team device scope",
42
43
  };
43
44
  /** How a census key is written when a person is being told to type it. */
44
45
  export function typedSpelling(verb) {
@@ -29,6 +29,8 @@
29
29
  import { readFileSync, readdirSync } from "node:fs";
30
30
  import { dirname, join, resolve } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
32
+ import { DEVICE_TWINS } from "./device-twins.js";
33
+ import { MANAGER_TWINS } from "./manager-twins.js";
32
34
  import { IMAGE_TWINS, MEMORY_STORE_TWINS } from "./memory-twins.js";
33
35
  const HERE = dirname(fileURLToPath(import.meta.url));
34
36
  export const COLLECTOR_COMMANDS_DIR = resolve(HERE, "../../cockpit-local-collector/src/commands");
@@ -349,9 +351,8 @@ export const MCP_TWINS = {
349
351
  "notes bot-upcoming": { tool: "notes_bot_upcoming", door: "GET /api/notetaker/upcoming" },
350
352
  "notes granola-status": { tool: "notes_granola_status", door: "GET /api/notes/granola-status" },
351
353
  "memory log": { tool: "memory_experience", door: "POST /api/memory/experience" },
352
- // BLI-4047, BLI-12927: the memory STORE at a terminal. Its twins live on the
353
- // second server, `bli-memory` (see `memory-twins.ts`, which also holds the image twins).
354
- ...MEMORY_STORE_TWINS, ...IMAGE_TWINS,
354
+ // BLI-4047, BLI-12927: the memory STORE (twins on the `bli-memory` server) and images: `memory-twins.ts`; BLI-5006: the Manager: `manager-twins.ts`; BLI-17978: the bot scope: `device-twins.ts`.
355
+ ...MEMORY_STORE_TWINS, ...IMAGE_TWINS, ...MANAGER_TWINS, ...DEVICE_TWINS,
355
356
  // BLI-4533: the compiled one-page profile. Its twin is on `bli-tower`, not
356
357
  // `bli-memory`: that server's four tools are the vendor-compatible set the
357
358
  // Claude Code plugin expects, and this page is ours.
@@ -410,8 +411,7 @@ export const MCP_TWINS = {
410
411
  // are.
411
412
  search: { tool: "tower_search", door: "GET /api/search" },
412
413
  jarvis: { tool: "jarvis_ask", door: "POST /api/jarvis/cli" },
413
- // Entered by hand: a mode flag, not an `action` the parser names (see the
414
- // header of this file).
414
+ // Entered by hand: a mode flag, not an `action` the parser names (see the header of this file).
415
415
  "jarvis --trace": { tool: "jarvis_trace", door: "GET /api/ops/trace/[id]" },
416
416
  };
417
417
  // Human command grammar is separate from the mechanical twin census.
@@ -421,7 +421,7 @@ export const TERMINAL_ONLY = {
421
421
  "mail add-imap": "carries a Google app password. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a mailbox stays a thing a person does at a terminal (BLI-3708)",
422
422
  "cal add-ical": "carries a calendar's SECRET iCal ADDRESS — a permanent, unauthenticated, read-anything-on-that-calendar URL. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a calendar stays a thing a person does at a terminal (BLI-3709)",
423
423
  "brief edit": "opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts)",
424
- "notes connect": "carries a notetaker credential: an API key (Fellow or Circleback), or for Wispr Flow a browser sign-in whose refresh token the CLI hands straight to Tower. The CLI reads a key from STDIN precisely so it never lands in an argument list, and a browser sign-in has no tool-call equivalent at all; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so connecting a notetaker stays a thing a person does at a terminal or in Tower's Settings (BLI-4383, BLI-12926)",
424
+ "notes connect": "an API-key connect (Fellow or Circleback) carries a notetaker credential. The CLI reads the key from STDIN precisely so it never lands in an argument list, and an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so connecting a key stays a thing a person does at a terminal or in Tower's Settings (BLI-4383). Wispr Flow carries no credential through a call: its sign-in is a link the person clicks, started and watched over the same doors by the MCP tools notes_connect_wisprflow and notes_connect_status (BLI-17973)",
425
425
  "notes webhook": "carries the VENDOR's webhook signing secret, the key an HMAC over every delivered payload is checked against. Exactly the ruling `notes connect` already carries, for the same credential shape: the CLI reads it from STDIN so it never lands in an argument list, and an MCP tool argument travels through a model's context window and whatever transcript store sits behind it (BLI-4394)",
426
426
  };
427
427
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.121",
3
+ "version": "0.1.125",
4
4
  "private": false,
5
5
  "description": "bli-tower: an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",