@bli-cockpit/mcp 0.1.3 → 0.1.5

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.
@@ -24,6 +24,15 @@
24
24
  * `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
25
25
  * `cockpit jarvis --json` prints. A script and an agent read one shape.
26
26
  *
27
+ * ## One conversation per call (BLI-3786)
28
+ *
29
+ * A thread is single-writer: two turns in one conversation at the same time
30
+ * read each other's messages, and one can answer the other's question. Every
31
+ * tool here therefore sends NO thread unless the caller named one, and the
32
+ * door mints a conversation for that call alone and reports it as `thread_id`.
33
+ * Passing that id back is how an agent continues deliberately. `"main"` is the
34
+ * shared terminal default nobody chose and is never sent from here.
35
+ *
27
36
  * ## The approval code, and what this surface can and cannot prove
28
37
  *
29
38
  * The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
@@ -32,16 +41,24 @@
32
41
  * and cannot compute a code for any plan, so an invented code is refused by
33
42
  * the dashboard exactly as it always was.
34
43
  *
35
- * Lock 2 is weaker on THIS surface than it is in a terminal, and saying so is
36
- * the honest thing to do. In a terminal the person types the code themselves.
37
- * Through MCP the code arrives as a tool argument, and the dashboard cannot
38
- * tell a code a person handed their agent from one the agent lifted out of the
39
- * previous answer by itself. So: this tool takes the code as a parameter, it
40
- * NEVER derives, guesses or fabricates one, its description tells the model in
41
- * plain words that the code must come from the person, and every relay is
42
- * logged (presence and length only, never the code). Anything stronger a
43
- * per-code single use, an out-of-band confirmation is a server-side change
44
- * to the gate itself and belongs with the gate, not here.
44
+ * Lock 2 is weaker on THIS surface than it is in a terminal: in a terminal the
45
+ * person types the code themselves, while through MCP it arrives as a tool
46
+ * argument and nothing on the wire distinguishes a code a person handed their
47
+ * agent from one the agent lifted out of the previous answer. BLI-3755 closed
48
+ * the two halves of that gap where the gate lives, not here:
49
+ *
50
+ * * `jarvis_ask` and `jarvis_dispatch` tell the dashboard they are an AGENT
51
+ * surface (`jarvis-door.ts` sends it on every turn), so a proposal comes
52
+ * back with a `proposal_id` and NO code. There is nothing in the previous
53
+ * answer to lift. The person fetches the code where a person reads — a
54
+ * Tower tab, their Slack DM, their own `cockpit jarvis` — and hands it on.
55
+ * * A code is spendable ONCE. A replay is refused with `approval_code_spent`,
56
+ * so a code that did reach an agent buys at most the one run the person
57
+ * approved.
58
+ *
59
+ * What is unchanged here: this server holds no secret and cannot compute a code
60
+ * for any plan, it never derives, guesses or fabricates one, and every relay is
61
+ * logged (presence and length only, never the code).
45
62
  */
46
63
  import { z } from "zod";
47
64
  import { callAgentDoor } from "./agent-door.js";
@@ -62,10 +79,12 @@ export function registerJarvisTools(server, deps) {
62
79
  title: "Ask JARVIS",
63
80
  description: "Asks JARVIS one question and returns its answer, the Source: lines behind it, this turn's "
64
81
  + "turn_id and the conversation's thread_id. Same JARVIS, same tool belt and same evidence "
65
- + "rules as Tower web chat, the Slack DM and `cockpit jarvis`. Pass thread to continue an "
66
- + "earlier conversation; pass the returned turn_id to jarvis_trace to see how the answer was "
67
- + "reached. JARVIS speaks as the person this machine is paired to and cannot be made to "
68
- + "speak as anyone else.",
82
+ + "rules as Tower web chat, the Slack DM and `cockpit jarvis`. A thread is SINGLE-WRITER: "
83
+ + "omit thread and this turn gets a FRESH conversation of its own, so two questions asked at "
84
+ + "the same time can never read each other's; pass the thread_id the answer returned to "
85
+ + "continue that conversation deliberately. Pass the returned turn_id to jarvis_trace to see "
86
+ + "how the answer was reached. JARVIS speaks as the person this machine is paired to and "
87
+ + "cannot be made to speak as anyone else.",
69
88
  inputSchema: {
70
89
  question: z.string().min(1).max(50_000).describe("What to ask, in plain words."),
71
90
  thread: z
@@ -73,7 +92,9 @@ export function registerJarvisTools(server, deps) {
73
92
  .min(1)
74
93
  .max(200)
75
94
  .optional()
76
- .describe('The conversation to continue. Omit for "main", the default terminal thread.'),
95
+ .describe("The thread_id of a conversation to continue, exactly as an earlier answer returned "
96
+ + 'it. Omit for a fresh conversation for this call alone. "main" is the shared '
97
+ + "default nobody chose, so it is treated as naming nothing here."),
77
98
  subject: z
78
99
  .string()
79
100
  .min(1)
@@ -97,7 +118,10 @@ export function registerJarvisTools(server, deps) {
97
118
  }, async (args) => withSession(deps, async (session) => {
98
119
  const { result } = await takeTurn(deps, session, "jarvis_ask", {
99
120
  question: String(args.question ?? ""),
100
- thread: typeof args.thread === "string" ? args.thread : "main",
121
+ // BLI-3786: no thread means no thread. The door mints one for this
122
+ // call and reports it as thread_id; sending "main" here would put
123
+ // every agent on one conversation, which is the bug this replaced.
124
+ ...(typeof args.thread === "string" ? { thread: args.thread } : {}),
101
125
  ...(typeof args.subject === "string" ? { subject: args.subject } : {}),
102
126
  ...(typeof args.date === "string" ? { date: args.date } : {}),
103
127
  ...(typeof args.model === "string" ? { model: args.model } : {}),
@@ -167,10 +191,13 @@ export function registerJarvisTools(server, deps) {
167
191
  title: "Dispatch a coding task to JARVIS's coding arm",
168
192
  description: "Asks JARVIS to run a coding task on the runner: it clones the repo, works, pushes a branch "
169
193
  + "and opens a pull request. It never merges. TWO CALLS, always. Call this once WITHOUT "
170
- + "approval_code to get the plan and the 8-character confirmation code back; show both to the "
171
- + "person; call it again with the code THEY give you and the same instruction. Never derive, "
172
- + "guess, or reuse a code the person has not just handed you — a code you produce yourself is "
173
- + "exactly what the approval gate exists to refuse.",
194
+ + "approval_code: you get the plan and a proposal_id back, and DELIBERATELY no code a code "
195
+ + "you can read is a code you could approve with. Show the person the plan and the "
196
+ + "proposal_id and ask them to fetch the code themself, in a Tower browser tab, their Slack "
197
+ + "DM with JARVIS, or their own `cockpit jarvis` terminal. Then call this again with the code "
198
+ + "THEY give you and the same instruction. A code approves exactly ONE run: a reused one is "
199
+ + "refused (approval_code_spent) and the answer is a fresh approval, never a retry. Never "
200
+ + "derive, guess, or reuse a code the person has not just handed you.",
174
201
  inputSchema: {
175
202
  instruction: z
176
203
  .string()
@@ -188,9 +215,15 @@ export function registerJarvisTools(server, deps) {
188
215
  .min(1)
189
216
  .max(64)
190
217
  .optional()
191
- .describe("The 8-character hex confirmation code THE PERSON read back to you from the previous "
192
- + "call's plan. Omit it on the first call."),
193
- thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
218
+ .describe("The 8-character hex confirmation code THE PERSON fetched for this proposal and handed "
219
+ + "to you. Omit it on the first call — that call is never given one."),
220
+ thread: z
221
+ .string()
222
+ .min(1)
223
+ .max(200)
224
+ .optional()
225
+ .describe("The thread_id of a conversation to continue. Omit for a fresh one — the approval "
226
+ + "code is bound to the PLAN, not to a conversation, so a dispatch needs no thread."),
194
227
  },
195
228
  }, async (args) => withSession(deps, async (session) => {
196
229
  const log = deps.log ?? defaultLog;
@@ -212,7 +245,7 @@ export function registerJarvisTools(server, deps) {
212
245
  const question = [
213
246
  code.length > 0
214
247
  ? "Dispatch this coding task to the coding arm now."
215
- : "Propose a coding task for the coding arm. Do not dispatch it yet — read back the plan and the confirmation code so the person can approve it.",
248
+ : "Propose a coding task for the coding arm. Do not dispatch it yet — read back the plan and the proposal id so the person can fetch the approval code themselves.",
216
249
  ...(typeof args.repo === "string" ? [`Repo: ${args.repo}`] : []),
217
250
  "",
218
251
  String(args.instruction ?? ""),
@@ -220,7 +253,7 @@ export function registerJarvisTools(server, deps) {
220
253
  ? ["", `The person approved this plan. Confirmation code: ${code.toLowerCase()}`]
221
254
  : []),
222
255
  ].join("\n");
223
- const { result } = await takeTurn(deps, session, "jarvis_dispatch", { question, thread: typeof args.thread === "string" ? args.thread : "main" }, remember);
256
+ const { result } = await takeTurn(deps, session, "jarvis_dispatch", { question, ...(typeof args.thread === "string" ? { thread: args.thread } : {}) }, remember);
224
257
  return result;
225
258
  }));
226
259
  register("jarvis_check", {
@@ -235,14 +268,19 @@ export function registerJarvisTools(server, deps) {
235
268
  .max(200)
236
269
  .optional()
237
270
  .describe("The task id JARVIS named when it dispatched. Omit to ask about the recent ones."),
238
- thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
271
+ thread: z
272
+ .string()
273
+ .min(1)
274
+ .max(200)
275
+ .optional()
276
+ .describe("The thread_id of a conversation to continue. Omit for a fresh one."),
239
277
  },
240
278
  }, async (args) => withSession(deps, async (session) => {
241
279
  const task = typeof args.task === "string" ? args.task.trim() : "";
242
280
  const question = task.length > 0
243
281
  ? `Check the coding task ${task} and tell me its status, its branch and its pull request if it has one.`
244
282
  : "Check the coding tasks dispatched for me recently and tell me the status of each.";
245
- const { result } = await takeTurn(deps, session, "jarvis_check", { question, thread: typeof args.thread === "string" ? args.thread : "main" }, remember);
283
+ const { result } = await takeTurn(deps, session, "jarvis_check", { question, ...(typeof args.thread === "string" ? { thread: args.thread } : {}) }, remember);
246
284
  return result;
247
285
  }));
248
286
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `notes_upload` / `notes_paste` / `notes_share` / `notes_unshare` /
3
+ * `notes_move` MCP tools (BLI-3756 batch 2) — putting a meeting note in,
4
+ * sharing it, taking it back and moving it to another shelf, over the same
5
+ * `/api/notes/**` doors `cockpit notes` calls with the same device token.
6
+ *
7
+ * Reads live in `notes-tools.ts`. Three rules this half keeps.
8
+ *
9
+ * **The local screen runs before the network, on the name first.** A path is
10
+ * refused for a secret-shaped NAME without its bytes ever being read, then for
11
+ * being missing, unreadable, empty or over 20 MB — the same four checks and
12
+ * the same sentences as `commands/notes-file.ts`, because the server's own
13
+ * guard is the authority and this only avoids spending a round trip on a file
14
+ * it is certain to refuse. If the two ever disagree, the server wins.
15
+ *
16
+ * **Sharing is deliberate.** `cockpit notes share` asks before it acts unless
17
+ * `--yes` is passed; a client has no keyboard, so `confirm: true` is the whole
18
+ * of that gate here and a share without it is refused in the CLI's own words.
19
+ * Taking a note BACK narrows who can read it, and nobody needs to be talked
20
+ * out of that — `notes_unshare` asks for nothing, exactly as the CLI does not.
21
+ *
22
+ * **A slow door is named, not hidden.** `/api/notes/upload` reads the whole
23
+ * note with one model call and is budgeted to 300 seconds server-side; this
24
+ * waits slightly longer so the server's named failure wins the race whenever
25
+ * it manages to send one. An MCP client's own deadline may still be shorter —
26
+ * the description says so rather than letting a cut call read as a refusal.
27
+ */
28
+ import { type ToolDeps } from "./tool-result.js";
29
+ export type NotesWriteDeps = ToolDeps;
30
+ /** Mirrors `UPLOADED_NOTE_MAX_BYTES`; a courtesy check, not a second authority. */
31
+ export declare const NOTE_FILE_MAX_BYTES: number;
32
+ export declare function registerNotesWriteTools(server: {
33
+ registerTool: (...args: never[]) => unknown;
34
+ }, deps: NotesWriteDeps): void;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `notes_upload` / `notes_paste` / `notes_share` / `notes_unshare` /
3
+ * `notes_move` MCP tools (BLI-3756 batch 2) — putting a meeting note in,
4
+ * sharing it, taking it back and moving it to another shelf, over the same
5
+ * `/api/notes/**` doors `cockpit notes` calls with the same device token.
6
+ *
7
+ * Reads live in `notes-tools.ts`. Three rules this half keeps.
8
+ *
9
+ * **The local screen runs before the network, on the name first.** A path is
10
+ * refused for a secret-shaped NAME without its bytes ever being read, then for
11
+ * being missing, unreadable, empty or over 20 MB — the same four checks and
12
+ * the same sentences as `commands/notes-file.ts`, because the server's own
13
+ * guard is the authority and this only avoids spending a round trip on a file
14
+ * it is certain to refuse. If the two ever disagree, the server wins.
15
+ *
16
+ * **Sharing is deliberate.** `cockpit notes share` asks before it acts unless
17
+ * `--yes` is passed; a client has no keyboard, so `confirm: true` is the whole
18
+ * of that gate here and a share without it is refused in the CLI's own words.
19
+ * Taking a note BACK narrows who can read it, and nobody needs to be talked
20
+ * out of that — `notes_unshare` asks for nothing, exactly as the CLI does not.
21
+ *
22
+ * **A slow door is named, not hidden.** `/api/notes/upload` reads the whole
23
+ * note with one model call and is budgeted to 300 seconds server-side; this
24
+ * waits slightly longer so the server's named failure wins the race whenever
25
+ * it manages to send one. An MCP client's own deadline may still be shorter —
26
+ * the description says so rather than letting a cut call read as a refusal.
27
+ */
28
+ import { readFile, stat } from "node:fs/promises";
29
+ import path from "node:path";
30
+ import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
31
+ import { z } from "zod";
32
+ import { callAgentDoor } from "./agent-door.js";
33
+ import { CONFIRM_INPUT, doorFailureText, errorResult, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
34
+ /** Mirrors `UPLOADED_NOTE_MAX_BYTES`; a courtesy check, not a second authority. */
35
+ export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
36
+ /** The route's own ceiling is 300 s. Wait past it so its sentence wins. */
37
+ const UPLOAD_DEADLINE_MS = 305_000;
38
+ const WRITE_DEADLINE_MS = 60_000;
39
+ const PASTE_MAX_CHARS = 2_000_000;
40
+ /** The sentence the CLI prints for a refusal caught locally, kept word for word. */
41
+ function refusalSentence(refusal, filePath) {
42
+ switch (refusal) {
43
+ case "file_not_found":
44
+ return `I could not find that file: ${filePath}`;
45
+ case "file_unreadable":
46
+ return `I could not read that file: ${filePath}`;
47
+ case "file_empty":
48
+ return `There is nothing in that file: ${filePath}`;
49
+ case "file_too_big":
50
+ return "That file is too big to put in as a note — keep it under 20 MB.";
51
+ case "looks_like_a_key_file":
52
+ // The same rule the server's gate applies, said the same way: the name
53
+ // is all it takes to decide, and looking inside to be sure would already
54
+ // be the thing the rule forbids.
55
+ return `That name looks like a key or credential file, so I did not open it: ${filePath}`;
56
+ }
57
+ }
58
+ /** Reads and locally screens one note file. Never throws. */
59
+ async function readNoteFile(filePath) {
60
+ const fileName = path.basename(filePath);
61
+ const refuse = (refusal) => ({
62
+ ok: false,
63
+ refusal,
64
+ sentence: refusalSentence(refusal, filePath),
65
+ });
66
+ if (isSecretLikePathSegment(fileName))
67
+ return refuse("looks_like_a_key_file");
68
+ let size;
69
+ try {
70
+ const info = await stat(filePath);
71
+ if (!info.isFile())
72
+ return refuse("file_unreadable");
73
+ size = info.size;
74
+ }
75
+ catch (error) {
76
+ return refuse(error.code === "ENOENT" ? "file_not_found" : "file_unreadable");
77
+ }
78
+ if (size === 0)
79
+ return refuse("file_empty");
80
+ if (size > NOTE_FILE_MAX_BYTES)
81
+ return refuse("file_too_big");
82
+ try {
83
+ return { ok: true, bytes: await readFile(filePath), fileName };
84
+ }
85
+ catch {
86
+ return refuse("file_unreadable");
87
+ }
88
+ }
89
+ /** The route's own words for what happened. Never rephrased here. */
90
+ function saidByTower(body) {
91
+ const said = [body.headline ?? "Tower answered without a sentence.", ...(body.lines ?? [])];
92
+ if (body.noteId)
93
+ said.push(`Note id: ${body.noteId}`);
94
+ return said.join("\n");
95
+ }
96
+ async function postForm(deps, session, tool, form) {
97
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/upload", form, UPLOAD_DEADLINE_MS);
98
+ if (!response.ok)
99
+ return errorResult(doorFailureText(tool, response));
100
+ const body = response.body;
101
+ const text = saidByTower(body);
102
+ // `stored: false` is the door saying it did not keep the note. That is a
103
+ // refusal with a reason, not a success with a caveat.
104
+ return body.stored === true
105
+ ? textResult(text, { ok: true, noteId: body.noteId ?? null, scope: body.scope ?? null })
106
+ : errorResult(text);
107
+ }
108
+ export function registerNotesWriteTools(server, deps) {
109
+ const register = registrarFor(server);
110
+ register("notes_upload", {
111
+ title: "Put a local file in as a Tower meeting note",
112
+ description: "Uploads a transcript or note file from THIS machine's disk — the same door `cockpit notes upload` uses. "
113
+ + "A name that looks like a key or credential file is refused without being opened; so is a missing, empty or "
114
+ + "over-20-MB file. Tower reads the whole note with one model call, so a large file can take a couple of "
115
+ + "minutes and your client may give up first — the upload keeps going; notes_list will show it.",
116
+ inputSchema: {
117
+ path: z.string().min(1).max(4096).describe("An absolute path on this machine."),
118
+ exclude: z
119
+ .string()
120
+ .min(1)
121
+ .max(2000)
122
+ .optional()
123
+ .describe("Anything in the note to leave out, in plain words — forwarded to the reader verbatim."),
124
+ },
125
+ }, async (args) => withSession(deps, async (session) => {
126
+ const filePath = String(args.path ?? "");
127
+ const read = await readNoteFile(filePath);
128
+ if (!read.ok)
129
+ return errorResult(`Refused (${read.refusal}): ${read.sentence} Nothing was sent.`);
130
+ const form = new FormData();
131
+ form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
132
+ if (args.exclude)
133
+ form.set("exclusions", String(args.exclude));
134
+ return postForm(deps, session, "notes_upload", form);
135
+ }));
136
+ register("notes_paste", {
137
+ title: "Put text in as a Tower meeting note",
138
+ description: "Stores text you already have as a meeting note — the same door as notes_upload, which is what "
139
+ + "`cockpit notes paste` posts to. The CLI takes the body on stdin; here it is an argument, so the text "
140
+ + "travels in the request and is never echoed back to you.",
141
+ inputSchema: {
142
+ text: z.string().min(1).max(PASTE_MAX_CHARS).describe("The note itself."),
143
+ name: z.string().min(1).max(300).optional().describe("What to call it. Tower names it by date if you do not."),
144
+ exclude: z.string().min(1).max(2000).optional().describe("Anything to leave out, in plain words."),
145
+ },
146
+ }, async (args) => withSession(deps, async (session) => {
147
+ const text = String(args.text ?? "");
148
+ if (text.trim() === "") {
149
+ return errorResult("Refused (empty_paste): there was nothing to paste. Nothing was sent.");
150
+ }
151
+ const form = new FormData();
152
+ form.set("text", text);
153
+ if (args.name)
154
+ form.set("name", String(args.name));
155
+ if (args.exclude)
156
+ form.set("exclusions", String(args.exclude));
157
+ return postForm(deps, session, "notes_paste", form);
158
+ }));
159
+ register("notes_share", {
160
+ title: "Share a Tower meeting note with the team",
161
+ description: "Lets everyone signed in read one note. Deliberate: refused without `confirm: true`, the same rule as the "
162
+ + "CLI's --yes. Use notes_unshare to take it back.",
163
+ inputSchema: {
164
+ note_id: z.string().min(1).max(200).describe("A note id, as notes_list or notes_shelf reports it."),
165
+ confirm: CONFIRM_INPUT,
166
+ },
167
+ }, async (args) => withSession(deps, async (session) => {
168
+ const refusal = unconfirmed(args, "Sharing a note lets everyone signed in read it. Pass confirm: true to do it without being asked.");
169
+ if (refusal)
170
+ return refusal;
171
+ return shareCall(deps, session, args, true);
172
+ }));
173
+ register("notes_unshare", {
174
+ title: "Take a shared Tower note back",
175
+ description: "Makes one note yours again. No confirmation: taking a note back narrows who can read it, and nobody needs "
176
+ + "to be talked out of that — the same asymmetry `cockpit notes unshare` has.",
177
+ inputSchema: { note_id: z.string().min(1).max(200).describe("A note id.") },
178
+ }, async (args) => withSession(deps, async (session) => shareCall(deps, session, args, false)));
179
+ register("notes_move", {
180
+ title: "Move a Tower meeting note to another shelf",
181
+ description: "Puts one note on a different shelf, or clears its shelf with `clear: true` — the empty string the browser's "
182
+ + "own move box sends. A shelf is free text; notes_shelves lists the ones in use.",
183
+ inputSchema: {
184
+ note_id: z.string().min(1).max(200).describe("A note id."),
185
+ to: z.string().min(1).max(200).optional().describe("The shelf to move it to."),
186
+ clear: z.boolean().optional().describe("Take it off every shelf instead."),
187
+ },
188
+ }, async (args) => withSession(deps, async (session) => {
189
+ const clearing = args.clear === true;
190
+ if (!clearing && !args.to) {
191
+ return errorResult("Refused (no_destination): say which shelf with `to`, or pass `clear: true`. Nothing was moved.");
192
+ }
193
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/move", { note_id: String(args.note_id ?? ""), category: clearing ? "" : String(args.to) }, WRITE_DEADLINE_MS);
194
+ if (!response.ok)
195
+ return errorResult(doorFailureText("notes_move", response));
196
+ const body = response.body;
197
+ const text = [body.headline ?? "Tower answered without a sentence.", ...(body.lines ?? [])].join("\n");
198
+ return body.ok === true
199
+ ? textResult(text, { ok: true, shelf: body.shelf ?? null })
200
+ : errorResult(text);
201
+ }));
202
+ }
203
+ /** Share and unshare are one door and one body; only the boolean differs. */
204
+ async function shareCall(deps, session, args, share) {
205
+ const tool = share ? "notes_share" : "notes_unshare";
206
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/share", { note_id: String(args.note_id ?? ""), share }, WRITE_DEADLINE_MS);
207
+ if (!response.ok)
208
+ return errorResult(doorFailureText(tool, response));
209
+ const body = response.body;
210
+ return textResult(saidByTower(body), { ok: true, shared: share, noteId: body.noteId ?? null });
211
+ }
@@ -11,16 +11,12 @@
11
11
  * unhealthy ones so an agent does not have to know the vocabulary to notice a
12
12
  * red board.
13
13
  *
14
- * `ops recompile` stays out: it spends a model call and wants its own gate on
15
- * this surface (batch 2).
14
+ * `ops_recompile` (BLI-3756 batch 2) is the one write here: it spends a model
15
+ * call, so it is the only tool in this file that can cost money, and the only
16
+ * one whose failure can leave the effect UNKNOWN rather than known.
16
17
  */
17
18
  import { type ToolDeps } from "./tool-result.js";
18
19
  export type OpsDeps = ToolDeps;
19
- /**
20
- * The verdicts that mean something is wrong, verbatim from `commands/ops.ts`.
21
- * `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
22
- * reader's input is CURRENT and its answer is bad.
23
- */
24
20
  export declare const UNHEALTHY_VERDICTS: Set<string>;
25
21
  export declare function registerOpsTools(server: {
26
22
  registerTool: (...args: never[]) => unknown;
package/dist/ops-tools.js CHANGED
@@ -11,8 +11,9 @@
11
11
  * unhealthy ones so an agent does not have to know the vocabulary to notice a
12
12
  * red board.
13
13
  *
14
- * `ops recompile` stays out: it spends a model call and wants its own gate on
15
- * this surface (batch 2).
14
+ * `ops_recompile` (BLI-3756 batch 2) is the one write here: it spends a model
15
+ * call, so it is the only tool in this file that can cost money, and the only
16
+ * one whose failure can leave the effect UNKNOWN rather than known.
16
17
  */
17
18
  import { z } from "zod";
18
19
  import { callAgentDoor } from "./agent-door.js";
@@ -22,7 +23,23 @@ import { doorFailureText, errorResult, registrarFor, textResult, queryString, wi
22
23
  * `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
23
24
  * reader's input is CURRENT and its answer is bad.
24
25
  */
25
- export const UNHEALTHY_VERDICTS = new Set(["stale", "never_produced", "unreadable", "failing"]);
26
+ /**
27
+ * A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
28
+ * seconds; waiting slightly past it is what lets this tool tell "Tower gave up"
29
+ * from "the network did", instead of hanging up first and blaming the wrong
30
+ * one. A client with a shorter deadline of its own will cut the call first —
31
+ * the description says so.
32
+ */
33
+ const RECOMPILE_DEADLINE_MS = 830_000;
34
+ const TOWER_RECOMPILE_CEILING_SECONDS = 800;
35
+ export const UNHEALTHY_VERDICTS = new Set([
36
+ "stale",
37
+ "never_produced",
38
+ "unreadable",
39
+ "failing",
40
+ // BLI-3762: a job that wrote less than it owed is not healthy. Amber counts.
41
+ "degraded",
42
+ ]);
26
43
  function numberOf(value) {
27
44
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
28
45
  }
@@ -148,4 +165,48 @@ export function registerOpsTools(server, deps) {
148
165
  coverage: read["coverage"] ?? null,
149
166
  });
150
167
  }));
168
+ register("ops_recompile", {
169
+ title: "Write one person's Tower page again",
170
+ description: "Compiles a daily page on demand — `cockpit ops recompile`, same door. It SPENDS A MODEL CALL and takes "
171
+ + "several minutes; pass `dry_run: true` to resolve the person and see which page is being served without "
172
+ + "compiling anything. Your own page always; anybody else's is admin only, decided on the server. Tower's own "
173
+ + "budget is 800 seconds — if that runs out the effect is UNKNOWN, not failed, and this says so.",
174
+ inputSchema: {
175
+ person: z.string().min(1).max(200).describe("An email, a display name, or a person id."),
176
+ dry_run: z.boolean().optional().describe("Resolve the person and read the current page; compile nothing."),
177
+ },
178
+ }, async (args) => withSession(deps, async (session) => {
179
+ const dryRun = args.dry_run === true;
180
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/ops/recompile", { person: String(args.person ?? ""), dryRun }, RECOMPILE_DEADLINE_MS);
181
+ if (!response.ok) {
182
+ // The ceiling is its own outcome, and its effect is UNKNOWN rather
183
+ // than failed: the compile may well have finished after the
184
+ // connection was cut. Said in the CLI's own words, because a caller
185
+ // told "it failed" would reasonably ask for it again and pay twice.
186
+ if (response.transportError) {
187
+ return errorResult(`Refused (recompile_ceiling_exceeded): Tower's ${TOWER_RECOMPILE_CEILING_SECONDS}-second budget for one `
188
+ + "recompile ran out before it answered. That is not the same as the compile failing: it may have "
189
+ + "finished after the connection was cut. Call brief_read for that person to see which page is being "
190
+ + "served now.");
191
+ }
192
+ return errorResult(doorFailureText("ops_recompile", response));
193
+ }
194
+ const body = response.body;
195
+ const status = typeof body["status"] === "string" ? body["status"] : "compiled";
196
+ const displayName = typeof body["displayName"] === "string" ? body["displayName"] : String(args.person ?? "");
197
+ const pageId = typeof body["pageId"] === "string" ? body["pageId"] : null;
198
+ if (status === "dry_run") {
199
+ const latest = typeof body["latestPageId"] === "string" ? body["latestPageId"] : null;
200
+ return textResult(`${displayName} is on the roster.\n`
201
+ + (latest
202
+ ? `The page being served for them right now is ${latest}.`
203
+ : "Nothing has ever been written for them.")
204
+ + "\nNothing was compiled — this was a dry run.", body);
205
+ }
206
+ return textResult(`Wrote ${displayName}'s page again: ${pageId ?? "(no id returned)"}\n`
207
+ + (body["isNowLive"] === true
208
+ ? "It is the page being served now."
209
+ : "It is NOT the page being served: a scheduled compile is stamped for a later delivery window, so that "
210
+ + "one still wins. Nothing was lost — this is a new version alongside it."), body);
211
+ }));
151
212
  }
@@ -14,11 +14,15 @@
14
14
  * signals and reports the totals in `coverage`; a truncated board and a quiet
15
15
  * board must never read alike.
16
16
  *
17
- * Moving a Scout card (`start`/`dismiss`/`undo`) is super_admin server-side and
18
- * stays in batch 2.
17
+ * Moving a Scout card (`scout_start`/`scout_dismiss`/`scout_undo`, BLI-3756
18
+ * batch 2) is super_admin server-side, so those three RELAY a decision rather
19
+ * than making one — and each reads the board first, so an id is matched
20
+ * exactly against cards that exist and a refusal still hands back the read.
19
21
  */
20
22
  import { type ToolDeps } from "./tool-result.js";
21
23
  export type PagesDeps = ToolDeps;
22
24
  export declare function registerPagesTools(server: {
23
25
  registerTool: (...args: never[]) => unknown;
24
26
  }, deps: PagesDeps): void;
27
+ /** The three verbs `cockpit scout` has beyond reading the board. */
28
+ export declare const SCOUT_ACTIONS: readonly ["start", "dismiss", "undo"];
@@ -14,8 +14,10 @@
14
14
  * signals and reports the totals in `coverage`; a truncated board and a quiet
15
15
  * board must never read alike.
16
16
  *
17
- * Moving a Scout card (`start`/`dismiss`/`undo`) is super_admin server-side and
18
- * stays in batch 2.
17
+ * Moving a Scout card (`scout_start`/`scout_dismiss`/`scout_undo`, BLI-3756
18
+ * batch 2) is super_admin server-side, so those three RELAY a decision rather
19
+ * than making one — and each reads the board first, so an id is matched
20
+ * exactly against cards that exist and a refusal still hands back the read.
19
21
  */
20
22
  import { z } from "zod";
21
23
  import { callAgentDoor } from "./agent-door.js";
@@ -120,4 +122,69 @@ export function registerPagesTools(server, deps) {
120
122
  .join("\n\n");
121
123
  return textResult(`WORKBOOK · ${projects.length} project(s) · ${docCount} document(s).${lines ? `\n\n${lines}` : ""}`, { projects });
122
124
  }));
125
+ // BLI-3756 batch 2: the three verbs that MOVE a Scout card. Deciding a card
126
+ // is super_admin server-side and reading the board is not, so a refusal here
127
+ // never costs the caller the read it already had — the open cards come back
128
+ // beside the refusal, exactly as `cockpit scout` leaves the board on stdout.
129
+ for (const action of SCOUT_ACTIONS) {
130
+ register(`scout_${action}`, {
131
+ title: SCOUT_TITLES[action],
132
+ description: SCOUT_DESCRIPTIONS[action],
133
+ inputSchema: {
134
+ experiment_id: z
135
+ .string()
136
+ .min(1)
137
+ .max(200)
138
+ .describe("A full experiment id, exactly as scout_board reports it. No prefixes on this surface."),
139
+ },
140
+ }, async (args) => withSession(deps, async (session) => {
141
+ const experimentId = String(args.experiment_id ?? "");
142
+ // The board is read FIRST, as the CLI does: it is what says whether
143
+ // the id names a card that exists, and it is what a refused caller
144
+ // still gets to keep.
145
+ const read = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/cockpit/scout");
146
+ if (!read.ok)
147
+ return errorResult(doorFailureText(`scout_${action}`, read));
148
+ const board = (read.body.board ?? {});
149
+ const open = board.experiments ?? [];
150
+ const card = [...open, ...(board.settled ?? [])].find((one) => one.id === experimentId);
151
+ if (!card) {
152
+ // The exact-match rule, and the list to fix a wrong guess from in
153
+ // one step. Never a prefix: a near-miss that resolved to the wrong
154
+ // card would settle an experiment nobody decided on.
155
+ const known = open.map((one) => ` ${one.id ?? "?"} ${one.title ?? ""}`).join("\n");
156
+ return errorResult(`Refused (unknown_experiment): no card on the Scout board has the id "${experimentId}". `
157
+ + `Ids come from scout_board and are matched exactly.${known ? `\n\nOpen cards:\n${known}` : ""}`);
158
+ }
159
+ const serverAction = action === "undo" ? "undo_dismiss" : action;
160
+ const applied = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/cockpit/scout", {
161
+ experiment_id: experimentId,
162
+ action: serverAction,
163
+ });
164
+ if (!applied.ok) {
165
+ const gate = applied.status === 403
166
+ ? " Deciding a Scout card is a super_admin action; reading the board is not."
167
+ : "";
168
+ return errorResult(`${doorFailureText(`scout_${action}`, applied)}${gate}`);
169
+ }
170
+ return textResult(`${SCOUT_PAST_TENSE[action]} ${experimentId} · ${card.claimSummary ?? card.title ?? "that card"}`, { ok: true, action: serverAction, experiment: applied.body.experiment ?? null });
171
+ }));
172
+ }
123
173
  }
174
+ /** The three verbs `cockpit scout` has beyond reading the board. */
175
+ export const SCOUT_ACTIONS = ["start", "dismiss", "undo"];
176
+ const SCOUT_TITLES = {
177
+ start: "Start a Scout experiment",
178
+ dismiss: "Dismiss a Scout card",
179
+ undo: "Restore a dismissed Scout card",
180
+ };
181
+ const SCOUT_DESCRIPTIONS = {
182
+ start: "Marks one experiment card as being run — `cockpit scout start`, same door. super_admin, decided on the server.",
183
+ dismiss: "Sets one experiment card aside — `cockpit scout dismiss`, same door. Reversible with scout_undo. super_admin, decided on the server.",
184
+ undo: "Puts a dismissed card back on the board — `cockpit scout undo`, which the door calls `undo_dismiss`. super_admin, decided on the server.",
185
+ };
186
+ const SCOUT_PAST_TENSE = {
187
+ start: "Started",
188
+ dismiss: "Dismissed",
189
+ undo: "Restored",
190
+ };