@joe-broadhead/opencode-granola 0.0.0 → 0.0.2

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/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  // src/index.ts
2
2
  import { Plugin } from "@opencode/plugin";
3
3
 
4
- // src/meta.ts
5
- import { readFileSync } from "node:fs";
4
+ // src/host/tools-rpc.ts
5
+ import { randomUUID } from "node:crypto";
6
+ import { Rpc as Rpc2 } from "@opencode/plugin/rpc";
6
7
 
7
8
  // src/errors.ts
8
9
  import { Error as ToolError } from "@opencode/plugin/promise/tool";
9
- var SIGN_IN_STEPS = "Run /connect and choose Granola, or run opencode mcp auth granola in a terminal.";
10
+ var SIGN_IN_STEPS = "Run /connect and choose Granola, then press o or Enter to open the sign-in page (in a terminal, opencode mcp auth granola opens it automatically).";
10
11
  function describe(problem) {
11
12
  return `${problem.code}: ${problem.message} Next step: ${problem.next}`;
12
13
  }
@@ -28,6 +29,7 @@ function shortReason(text3) {
28
29
  if (!reason) return "no reason given";
29
30
  return reason.length > 160 ? `${reason.slice(0, 157)}...` : reason;
30
31
  }
32
+ var sentence = (reason) => /[.?!]$/.test(reason) ? reason : `${reason}.`;
31
33
  function connectionProblem(state, error) {
32
34
  switch (state) {
33
35
  case "connected":
@@ -36,7 +38,7 @@ function connectionProblem(state, error) {
36
38
  return {
37
39
  code: "NOT_REGISTERED",
38
40
  message: "The Granola connection isn't registered yet.",
39
- next: "Retry in a few seconds; if it persists, check that plugins in your OpenCode config lists the Granola plugin and that no mcp.servers.granola entry points elsewhere."
41
+ next: "Retry in a few seconds; if it persists, check that no organisation policy (mcp.use) or other plugin removes the granola server, then restart OpenCode."
40
42
  };
41
43
  case "pending":
42
44
  return { code: "CONNECTING", message: "Granola is still connecting.", next: "Retry in a few seconds." };
@@ -45,14 +47,14 @@ function connectionProblem(state, error) {
45
47
  case "failed":
46
48
  return {
47
49
  code: "CONNECTION_FAILED",
48
- message: `Granola connection failed: ${shortReason(error)}.`,
50
+ message: `Granola connection failed: ${sentence(shortReason(error))}`,
49
51
  next: "Reconnect Granola from /mcps, and if it keeps failing, run /connect and choose Granola to sign in again."
50
52
  };
51
53
  case "disabled":
52
54
  return {
53
55
  code: "DISABLED",
54
- message: "Granola is disabled in your OpenCode config.",
55
- next: "Remove disabled: true from mcp.servers.granola in your OpenCode config."
56
+ message: "Granola is disabled in OpenCode.",
57
+ next: 'Enable granola in /mcps, or remove "disabled": true from mcp.servers.granola in your OpenCode config.'
56
58
  };
57
59
  }
58
60
  }
@@ -75,11 +77,11 @@ function invalidOption(key, problem) {
75
77
  next: "Fix the Granola plugin options in your OpenCode config, then reload OpenCode."
76
78
  };
77
79
  }
78
- function hostUntested(tested, found) {
80
+ function hostUntested(range, found) {
79
81
  return {
80
82
  code: "HOST_UNTESTED",
81
- message: `The Granola plugin is tested with OpenCode ${tested}, found ${found}.`,
82
- next: "Use a tested OpenCode version, or set the plugin option allowUntestedHost to true to run anyway."
83
+ message: `The Granola plugin supports OpenCode ${range}, found ${found || "no version"}.`,
84
+ next: `Use OpenCode ${range}, or set the plugin option allowUntestedHost to true to run anyway.`
83
85
  };
84
86
  }
85
87
  function skillsInvalid(path3) {
@@ -93,17 +95,6 @@ function packageInvalid(problem) {
93
95
  return { code: "PACKAGE_INVALID", message: `The Granola plugin's package.json ${problem}.`, next: "Reinstall the plugin." };
94
96
  }
95
97
 
96
- // src/meta.ts
97
- var PLUGIN_ID = "granola.meetings";
98
- function readPackageInfo(url = new URL("../package.json", import.meta.url)) {
99
- const raw = JSON.parse(readFileSync(url, "utf8"));
100
- const record3 = (value) => typeof value === "object" && value !== null;
101
- if (!record3(raw) || typeof raw.version !== "string" || !record3(raw.opencode) || typeof raw.opencode.host !== "string") {
102
- throw new GranolaError(packageInvalid("needs a version and an opencode.host range"));
103
- }
104
- return { version: raw.version, testedHost: raw.opencode.host };
105
- }
106
-
107
98
  // src/known-tools.ts
108
99
  var SERVER = "granola";
109
100
  var READ_TOOLS = [
@@ -143,6 +134,18 @@ function classify(id) {
143
134
  return "unknown";
144
135
  }
145
136
 
137
+ // src/meta.ts
138
+ import { readFileSync } from "node:fs";
139
+ var PLUGIN_ID = "granola.meetings";
140
+ function readPackageInfo(url = new URL("../package.json", import.meta.url)) {
141
+ const raw = JSON.parse(readFileSync(url, "utf8"));
142
+ const record4 = (value) => typeof value === "object" && value !== null;
143
+ if (!record4(raw) || typeof raw.version !== "string" || !record4(raw.opencode) || typeof raw.opencode.host !== "string") {
144
+ throw new GranolaError(packageInvalid("needs a version and an opencode.host range"));
145
+ }
146
+ return { version: raw.version, hostRange: raw.opencode.host };
147
+ }
148
+
146
149
  // src/permissions.ts
147
150
  var DEFAULT_RULES = [
148
151
  { action: "granola_*", resource: "*", effect: "ask" },
@@ -167,15 +170,12 @@ function matches(input, pattern) {
167
170
  const source = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
168
171
  return new RegExp(`^${source}$`, "s").test(input);
169
172
  }
170
- function winner(action, rules) {
171
- return rules.findLast((rule) => matches(action, rule.action) && matches("*", rule.resource));
173
+ function evaluate(rules, action) {
174
+ return rules.findLast((rule) => matches(action, rule.action) && matches("*", rule.resource))?.effect ?? "ask";
172
175
  }
173
- var SIDE_EFFECTS = [...WRITE_TOOLS.map(toolID), toolID("unknown_tool")];
176
+ var UNKNOWN_TOOL = toolID("unknown_tool");
174
177
  function unguardedAgents(agents) {
175
- return agents.filter((agent) => !hasDefaults(agent.permissions)).filter((agent) => SIDE_EFFECTS.some((action) => {
176
- const rule = winner(action, agent.permissions);
177
- return rule?.effect === "allow" && !rule.action.startsWith("granola");
178
- })).map((agent) => agent.id);
178
+ return agents.filter((agent) => agent.permissions.some(isBaseAllow) && !hasDefaults(agent.permissions) && evaluate(agent.permissions, UNKNOWN_TOOL) === "allow").map((agent) => agent.id);
179
179
  }
180
180
  async function registerPermissions(ctx) {
181
181
  await ctx.agent.transform((editor) => {
@@ -185,18 +185,34 @@ async function registerPermissions(ctx) {
185
185
 
186
186
  // src/rpc/contract.ts
187
187
  import { Rpc } from "@opencode/plugin/rpc";
188
+ var STATES = ["connected", "sign_in_required", "connecting", "disabled", "failed", "not_configured", "conflict"];
189
+ var STATE_OF = {
190
+ connected: "connected",
191
+ pending: "connecting",
192
+ needs_auth: "sign_in_required",
193
+ failed: "failed",
194
+ disabled: "disabled",
195
+ absent: "not_configured"
196
+ };
188
197
  var STATUS_SCHEMA = {
189
198
  type: "object",
190
199
  additionalProperties: false,
191
- required: ["state", "tools", "unguardedAgents", "exportDirectory", "pluginVersion", "hostVersion", "next"],
200
+ required: ["version", "host", "state", "tools", "unguardedAgents", "exportDirectory", "nextStep"],
192
201
  properties: {
193
- state: { type: "string", enum: ["connected", "pending", "disabled", "failed", "needs_auth", "absent"] },
202
+ version: { type: "string" },
203
+ host: {
204
+ type: "object",
205
+ additionalProperties: false,
206
+ required: ["version", "range", "tested"],
207
+ properties: { version: { type: ["string", "null"] }, range: { type: "string" }, tested: { type: "boolean" } }
208
+ },
209
+ state: { type: "string", enum: STATES },
194
210
  title: { type: "string" },
195
211
  problem: {
196
212
  type: "object",
197
213
  additionalProperties: false,
198
- required: ["code", "message", "next"],
199
- properties: { code: { type: "string" }, message: { type: "string" }, next: { type: "string" } }
214
+ required: ["code", "message"],
215
+ properties: { code: { type: "string" }, message: { type: "string" } }
200
216
  },
201
217
  conflict: { type: "string" },
202
218
  tools: {
@@ -226,31 +242,43 @@ var STATUS_SCHEMA = {
226
242
  }
227
243
  },
228
244
  exportDirectory: { type: "string" },
229
- pluginVersion: { type: "string" },
230
- hostVersion: { type: "string" },
231
245
  hostWarning: { type: "string" },
232
- next: { type: "string" }
246
+ nextStep: { type: "string" }
233
247
  }
234
248
  };
249
+ var STATUS_ERROR_SCHEMA = {
250
+ type: "object",
251
+ additionalProperties: false,
252
+ required: ["code", "next"],
253
+ properties: { code: { type: "string" }, next: { type: "string" } }
254
+ };
235
255
  var STATE_LABELS = {
236
256
  connected: "connected",
237
- pending: "connecting",
238
- needs_auth: "sign-in required",
257
+ connecting: "connecting",
258
+ sign_in_required: "sign-in required",
239
259
  failed: "failed",
240
260
  disabled: "disabled",
241
- absent: "not configured"
261
+ not_configured: "not configured",
262
+ conflict: "conflict"
242
263
  };
243
264
  var TONES = {
244
265
  connected: "ok",
245
- pending: "info",
246
- needs_auth: "warning",
266
+ connecting: "info",
267
+ sign_in_required: "warning",
247
268
  failed: "error",
248
269
  disabled: "info",
249
- absent: "warning"
270
+ not_configured: "warning",
271
+ conflict: "warning"
250
272
  };
273
+ function hostRow(status) {
274
+ const { version, range, tested } = status.host;
275
+ const found = version ?? "unknown";
276
+ if (status.hostWarning) return { label: "OpenCode", text: `${found} (host untested: ${status.hostWarning}), plugin ${status.version}`, tone: "warning" };
277
+ return { label: "OpenCode", text: `${found} (${tested ? "tested" : "untested"}; supported range ${range}), plugin ${status.version}`, ...tested ? {} : { tone: "info" } };
278
+ }
251
279
  function statusRows(status) {
252
280
  const connected = status.state === "connected";
253
- const connection = status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
281
+ const connection2 = status.state === "conflict" && status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
254
282
  const tools = [`${status.tools.visible} available`];
255
283
  if (status.tools.hidden) tools.push(`${status.tools.hidden} hidden`);
256
284
  if (status.tools.unknown) {
@@ -259,18 +287,18 @@ function statusRows(status) {
259
287
  }
260
288
  const agents = status.unguardedAgents;
261
289
  return [
262
- { label: "Connection", text: connection, tone: status.conflict ? "warning" : TONES[status.state] },
290
+ { label: "Connection", text: connection2, tone: TONES[status.state] },
263
291
  { label: "Account", text: !connected ? "unknown until Granola connects" : status.workspace ? `workspace ${status.workspace}` : "workspace unknown until the agent reads your Granola account" },
264
292
  { label: "Access", text: "reads run without asking; changes, new tools and exports ask first" },
265
293
  { label: "Export folder", text: status.exportDirectory },
266
294
  { label: "Tools", text: connected ? tools.join(", ") : "none until Granola connects" },
267
- ...agents.length ? [{
295
+ agents.length ? {
268
296
  label: "Agents without default rules",
269
- text: `${agents.join(", ")}: Granola writes run there without asking`,
297
+ text: `${agents.join(", ")}: Granola changes and new tools may run there without asking`,
270
298
  tone: "warning",
271
299
  block: permissionsSnippet(agents, status.permissionsBlock ?? [])
272
- }] : [],
273
- status.hostWarning ? { label: "OpenCode", text: `${status.hostVersion} (host untested: ${status.hostWarning}), plugin ${status.pluginVersion}`, tone: "warning" } : { label: "OpenCode", text: `${status.hostVersion} (tested), plugin ${status.pluginVersion}` }
300
+ } : { label: "Agents without default rules", text: "none" },
301
+ hostRow(status)
274
302
  ];
275
303
  }
276
304
  function permissionsSnippet(agents, block2) {
@@ -292,34 +320,41 @@ var Granola = Rpc.define({
292
320
  methods: {
293
321
  status: {
294
322
  input: { type: "object", properties: {}, additionalProperties: false },
295
- output: STATUS_SCHEMA
323
+ output: STATUS_SCHEMA,
324
+ errors: { unavailable: STATUS_ERROR_SCHEMA }
296
325
  }
297
326
  },
298
327
  events: {}
299
328
  });
300
329
 
330
+ // src/hosts.ts
331
+ var TESTED_HOSTS = ["2.2.3", "2.2.4", "2.2.5"];
332
+
301
333
  // src/status.ts
302
334
  var LISTED_UNKNOWN = 20;
303
335
  var SAFE_NAME = /^[a-z][a-z0-9_]{0,63}$/;
304
336
  var CONFLICT_NEXT = "Remove mcp.servers.granola from your OpenCode config to use Granola's official server.";
305
337
  var NOTHING_TO_DO = "Nothing to do.";
338
+ var UNTESTED_HOST_NEXT = `If Granola misbehaves, use OpenCode ${TESTED_HOSTS.slice(0, -1).join(", ")} or ${TESTED_HOSTS.at(-1)}.`;
306
339
  function agentFix(agents) {
307
340
  const where = agents.map((agent) => `agents.${agent}.permissions`).join(", ");
308
341
  return `Put the Granola block first in that agent's own permissions (${where}), followed by your top-level rules for granola_ actions.`;
309
342
  }
310
343
  function buildStatus(inputs) {
311
344
  const server = inputs.servers.find((entry) => entry.name === SERVER);
312
- const state = server?.status.status ?? "absent";
313
- const problem = connectionProblem(state, server?.status.error);
345
+ const connection2 = server?.status.status ?? "absent";
346
+ const problem = inputs.conflict ? void 0 : connectionProblem(connection2, server?.status.error);
314
347
  const classes = inputs.tools.map((tool) => ({ id: tool.id, kind: classify(tool.id) }));
315
348
  const unknown = classes.filter((tool) => tool.kind === "unknown").map((tool) => tool.id.slice(SERVER.length + 1));
316
- const visible = classes.filter((tool) => tool.kind === "read" || tool.kind === "write").length;
349
+ const visible = classes.filter((tool) => tool.kind === "read" || tool.kind === "write" || tool.kind === "unknown").length;
317
350
  const unguarded = unguardedAgents(inputs.agents);
318
- const next = (inputs.conflict ? CONFLICT_NEXT : void 0) ?? problem?.next ?? (unguarded.length ? agentFix(unguarded) : void 0) ?? NOTHING_TO_DO;
351
+ const nextStep = (inputs.conflict ? CONFLICT_NEXT : void 0) ?? problem?.next ?? (unguarded.length ? agentFix(unguarded) : void 0) ?? (inputs.host.warning ? UNTESTED_HOST_NEXT : void 0) ?? NOTHING_TO_DO;
319
352
  return {
320
- state,
321
- ...state === "connected" && server?.title ? { title: server.title } : {},
322
- ...problem ? { problem } : {},
353
+ version: inputs.version,
354
+ host: { version: inputs.host.version || null, range: inputs.host.range, tested: inputs.host.tested },
355
+ state: inputs.conflict ? "conflict" : STATE_OF[connection2],
356
+ ...connection2 === "connected" && !inputs.conflict && server?.title ? { title: server.title } : {},
357
+ ...problem ? { problem: { code: problem.code, message: problem.message } } : {},
323
358
  ...inputs.conflict ? { conflict: inputs.conflict } : {},
324
359
  tools: {
325
360
  visible,
@@ -331,10 +366,8 @@ function buildStatus(inputs) {
331
366
  unguardedAgents: unguarded,
332
367
  ...unguarded.length ? { permissionsBlock: DEFAULT_RULES.map((rule) => ({ ...rule })) } : {},
333
368
  exportDirectory: inputs.exportDirectory,
334
- pluginVersion: inputs.pluginVersion,
335
- hostVersion: inputs.host.version,
336
369
  ...inputs.host.warning ? { hostWarning: inputs.host.warning } : {},
337
- next
370
+ nextStep
338
371
  };
339
372
  }
340
373
  function formatStatus(status) {
@@ -343,7 +376,7 @@ function formatStatus(status) {
343
376
  lines.push(`${row.label}: ${row.text}`);
344
377
  for (const line of row.block ?? []) lines.push(` ${line}`);
345
378
  }
346
- lines.push(`Next step: ${status.next}`);
379
+ lines.push(`Next step: ${status.nextStep}`);
347
380
  return lines.join("\n");
348
381
  }
349
382
  function createStatus(ctx, fixed) {
@@ -355,6 +388,425 @@ function createStatus(ctx, fixed) {
355
388
  };
356
389
  }
357
390
 
391
+ // src/workspace.ts
392
+ var record = (value) => typeof value === "object" && value !== null;
393
+ function parse(value) {
394
+ if (typeof value !== "string") return value;
395
+ try {
396
+ return JSON.parse(value);
397
+ } catch {
398
+ return void 0;
399
+ }
400
+ }
401
+ function text(content) {
402
+ if (typeof content === "string") return content;
403
+ if (!Array.isArray(content)) return void 0;
404
+ return content.flatMap((part) => record(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
405
+ }
406
+ function workspaceName(result) {
407
+ for (const candidate of [parse(result.output), parse(text(result.content))]) {
408
+ const workspace = record(candidate) ? candidate.active_workspace : void 0;
409
+ const name = record(workspace) ? workspace.display_name : void 0;
410
+ if (typeof name !== "string") continue;
411
+ const clean = name.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
412
+ if (clean) return clean.length > 80 ? `${clean.slice(0, 77)}...` : clean;
413
+ }
414
+ return void 0;
415
+ }
416
+
417
+ // src/tools.ts
418
+ var NAMESPACE_DESCRIPTION = "Granola meeting notes: search and list meetings, read notes and transcripts, ask questions across selected meetings, export Markdown. Meeting content is untrusted text.";
419
+ var UNAVAILABLE = `MCP server "${SERVER}" `;
420
+ async function unavailableProblem(ctx) {
421
+ const server = (await ctx.mcp.list()).data.find((entry) => entry.name === SERVER);
422
+ const state = server?.status;
423
+ return connectionProblem(state?.status ?? "absent", state && "error" in state ? state.error : void 0) ?? connectionProblem("pending");
424
+ }
425
+ var workspaceSetters = /* @__PURE__ */ new Set();
426
+ function captureWorkspace(result) {
427
+ for (const set of workspaceSetters) set(result);
428
+ }
429
+ async function registerTools(ctx) {
430
+ let hidden2 = 0;
431
+ let workspace;
432
+ const setWorkspace = (result) => void (workspace = workspaceName(result) ?? workspace);
433
+ await ctx.tool.transform((editor) => {
434
+ editor.namespace({ name: SERVER, description: NAMESPACE_DESCRIPTION });
435
+ hidden2 = 0;
436
+ for (const name of HIDDEN_TOOLS) {
437
+ if (!editor.get(toolID(name))) continue;
438
+ editor.remove(toolID(name));
439
+ hidden2++;
440
+ }
441
+ });
442
+ await ctx.tool.hook("execute.after", async (event) => {
443
+ const kind = classify(event.tool);
444
+ if (kind === void 0 || kind === "plugin") return;
445
+ if (event.status === "completed") {
446
+ if (event.tool === toolID("get_account_info")) setWorkspace(event.result);
447
+ return;
448
+ }
449
+ if (!event.error.message.startsWith(UNAVAILABLE)) return;
450
+ try {
451
+ event.error = toolError(await unavailableProblem(ctx));
452
+ } catch {
453
+ }
454
+ });
455
+ workspaceSetters.add(setWorkspace);
456
+ return { hidden: () => hidden2, workspace: () => workspace, dispose: () => void workspaceSetters.delete(setWorkspace) };
457
+ }
458
+
459
+ // src/host/tools-rpc.ts
460
+ var PLUGIN_ID2 = PLUGIN_ID;
461
+ var KNOWN_READS = READ_TOOLS;
462
+ function shapeResult(method, result) {
463
+ if (method === "get_account_info") captureWorkspace({ output: result.output, content: result.text });
464
+ return result;
465
+ }
466
+ var connectionNext = (state) => connectionProblem(state)?.next ?? "";
467
+ var NEXT_STEPS = {
468
+ SESSION_UNKNOWN: `Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.`,
469
+ SESSION_AGENT_REQUIRED: `Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.`,
470
+ PERMISSION_DENIED: "Add an allow rule for the tool to the session's agent or to the session, or use a session whose agent allows it.",
471
+ SERVER_CONFLICT: CONFLICT_NEXT,
472
+ AUTH_REQUIRED: connectionNext("needs_auth"),
473
+ CONNECTING: connectionNext("pending"),
474
+ CONNECTION_FAILED: connectionNext("failed"),
475
+ DISABLED: connectionNext("disabled"),
476
+ NOT_REGISTERED: connectionNext("absent"),
477
+ TOOL_UNAVAILABLE: toolUnavailable("the tool").next,
478
+ UPSTREAM_ERROR: "Check the input against the method's schema from describe, and run /granola-status if it keeps failing.",
479
+ CANCELLED: "Call the method again if you still need the result."
480
+ };
481
+ function pluginError(_error) {
482
+ return void 0;
483
+ }
484
+ var TOOLS_RPC_ID = `${PLUGIN_ID2}.tools`;
485
+ var UNATTENDED = "A script cannot answer an approval prompt; this needs an allow rule for the session's agent.";
486
+ var MESSAGE_ID = "msg_script";
487
+ var UPSTREAM_LIMIT = 300;
488
+ var object = (properties, required) => ({
489
+ type: "object",
490
+ properties,
491
+ required,
492
+ additionalProperties: false
493
+ });
494
+ var STRING = { type: "string" };
495
+ var ANY_OBJECT = { type: "object" };
496
+ var NO_INPUT = object({}, []);
497
+ var FAILED = object({ code: STRING, next: STRING }, ["code", "next"]);
498
+ var mcpOutput = (output = {}) => object({ output, text: STRING }, ["text"]);
499
+ var DESCRIBED = object(
500
+ {
501
+ name: STRING,
502
+ tool: STRING,
503
+ kind: { type: "string", enum: ["mcp", "plugin"] },
504
+ action: STRING,
505
+ description: STRING,
506
+ available: { type: "boolean" },
507
+ reason: STRING,
508
+ input: ANY_OBJECT,
509
+ output: ANY_OBJECT
510
+ },
511
+ ["name", "tool", "kind", "action", "description", "available", "input", "output"]
512
+ );
513
+ var DESCRIPTION = object({ id: STRING, call: STRING, location: STRING, methods: { type: "array", items: DESCRIBED } }, [
514
+ "id",
515
+ "call",
516
+ "location",
517
+ "methods"
518
+ ]);
519
+ var plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
520
+ var ENVELOPE_MESSAGES = {
521
+ notObject: `the envelope must be an object, such as {"sessionID":"<session>","input":<the tool's input>}`,
522
+ unknownKey: (key) => `"${key}" is not allowed`,
523
+ sessionID: `"sessionID" must be a non-empty string`,
524
+ account: `"account" must be a non-empty string`,
525
+ inputMissing: `"input" is missing`,
526
+ inputNotObject: `"input" must be an object`,
527
+ describe: "describe takes no input"
528
+ };
529
+ var standard = (schema, validate) => Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "tools-rpc", validate }, enumerable: false });
530
+ var DESCRIBE_INPUT = standard(
531
+ NO_INPUT,
532
+ (value) => value === void 0 || value === null || plainObject(value) && Object.keys(value).length === 0 ? { value } : { issues: [{ message: ENVELOPE_MESSAGES.describe }] }
533
+ );
534
+ function strict(schema, issues) {
535
+ const keys = Object.keys(schema["properties"]);
536
+ return standard(schema, (value) => {
537
+ if (!plainObject(value)) return { issues: [{ message: ENVELOPE_MESSAGES.notObject }] };
538
+ const found = [...Object.keys(value).filter((key) => !keys.includes(key)).map(ENVELOPE_MESSAGES.unknownKey), ...issues(value)];
539
+ return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
540
+ });
541
+ }
542
+ var ScriptError = class extends Error {
543
+ failure;
544
+ constructor(failure) {
545
+ super(`${failure.code}: ${failure.what.replace(/[.\s]+$/, "")}. Next step: ${failure.next}`);
546
+ this.failure = failure;
547
+ }
548
+ };
549
+ var fail = (code2, what) => new ScriptError({ code: code2, what, next: NEXT_STEPS[code2] });
550
+ var record2 = (value) => typeof value === "object" && value !== null;
551
+ function notFound(error, tags) {
552
+ if (!record2(error)) return false;
553
+ const tag = error["_tag"] ?? error["name"];
554
+ return typeof tag === "string" && tags.includes(tag) || error["status"] === 404 || error["statusCode"] === 404;
555
+ }
556
+ var toolError2 = (error) => record2(error) && error["_tag"] === "Tool.Error";
557
+ var requestAction = (cause) => {
558
+ const request = record2(cause) ? cause["request"] : void 0;
559
+ return record2(request) && typeof request["action"] === "string" && request["action"] !== "" ? request["action"] : void 0;
560
+ };
561
+ var wildcard = (pattern, value) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(value);
562
+ var hidden = (rules, action) => rules.findLast((rule) => wildcard(rule.action, action) && wildcard(rule.resource, "*"))?.effect === "deny";
563
+ var SECRETS = [
564
+ [/\b(bearer|basic|token)\s+[\w.~+/=-]{8,}/gi, "$1 [redacted]"],
565
+ [/\b([\w-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|token|secret|password|signature|credential)["']?\s*[:=]\s*)["']?[^\s"'&,;]+["']?/gi, "$1[redacted]"],
566
+ [/\beyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]{4,}/g, "[redacted]"],
567
+ [/\b(?:tvly|sk|pk|rk|ghp|gho|ghu|ghs|ghr|github_pat|glpat|xox[abposr]|lin_api|lin_oauth)[-_][\w-]{8,}/gi, "[redacted]"],
568
+ [/[\w+=-]{40,}/g, "[redacted]"]
569
+ ];
570
+ var withoutQuery = (text3) => text3.replace(/\bhttps?:\/\/[^\s"'<>()]+/gi, (url) => {
571
+ const cut = url.search(/[?#]/);
572
+ return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`;
573
+ });
574
+ function upstreamText(text3) {
575
+ const line = text3.split(/\r?\n/).find((part) => part.trim() !== "")?.trim() ?? "no message";
576
+ const redacted = SECRETS.reduce((value, [pattern, replacement]) => value.replace(pattern, replacement), withoutQuery(line));
577
+ return redacted.length <= UPSTREAM_LIMIT ? redacted : `${redacted.slice(0, UPSTREAM_LIMIT - 1)}\u2026`;
578
+ }
579
+ var jsonSchema = (schema) => record2(schema) && !("ast" in schema) && !("~standard" in schema) ? schema : void 0;
580
+ function normalise(result) {
581
+ const text3 = typeof result.content === "string" ? result.content : (result.content ?? []).flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
582
+ return result.output === void 0 ? { text: text3 } : { output: result.output, text: text3 };
583
+ }
584
+ function connection(status) {
585
+ switch (status) {
586
+ case "connected":
587
+ return void 0;
588
+ case "pending":
589
+ return "CONNECTING";
590
+ case "needs_auth":
591
+ return "AUTH_REQUIRED";
592
+ case "failed":
593
+ return "CONNECTION_FAILED";
594
+ case "disabled":
595
+ return "DISABLED";
596
+ default:
597
+ return "NOT_REGISTERED";
598
+ }
599
+ }
600
+ var STATUS_TEXT = {
601
+ CONNECTING: "is still connecting",
602
+ AUTH_REQUIRED: "needs a sign-in",
603
+ CONNECTION_FAILED: "could not connect",
604
+ DISABLED: "is disabled",
605
+ NOT_REGISTERED: "is not registered"
606
+ };
607
+ async function registerToolsRpc(ctx, options) {
608
+ const { mcp, owned = [] } = options;
609
+ const accounts = typeof mcp?.server === "function";
610
+ if (KNOWN_READS.length > 0 && !mcp) throw new Error("registerToolsRpc: KNOWN_READS needs the mcp option");
611
+ if (accounts && NEXT_STEPS.ACCOUNT_UNKNOWN === void 0) throw new Error("registerToolsRpc: per-account servers need NEXT_STEPS.ACCOUNT_UNKNOWN");
612
+ const names = [...KNOWN_READS, ...owned.map((method) => method.name)];
613
+ const duplicate = names.find((name, index) => name === "describe" || names.indexOf(name) !== index);
614
+ if (duplicate) throw new Error(`registerToolsRpc: the method name "${duplicate}" is taken`);
615
+ const live = /* @__PURE__ */ new Set();
616
+ const refused = /* @__PURE__ */ new Map();
617
+ const serverOf = (account) => {
618
+ const server = typeof mcp.server === "function" ? mcp.server(account) : mcp.server;
619
+ if (server !== void 0) return server;
620
+ throw new ScriptError({ code: "ACCOUNT_UNKNOWN", what: `there is no account '${account}'`, next: NEXT_STEPS.ACCOUNT_UNKNOWN });
621
+ };
622
+ const session = async (sessionID) => {
623
+ let info;
624
+ try {
625
+ info = await ctx.session.get({ sessionID });
626
+ } catch (error) {
627
+ if (notFound(error, ["Session.NotFoundError", "SessionNotFoundError"])) throw fail("SESSION_UNKNOWN", `there is no session '${sessionID}'`);
628
+ throw error;
629
+ }
630
+ if (!info.agent) throw fail("SESSION_AGENT_REQUIRED", `session '${sessionID}' names no agent`);
631
+ let agent;
632
+ try {
633
+ agent = await ctx.agent.get({ agentID: info.agent, location: info.location });
634
+ } catch (error) {
635
+ if (notFound(error, ["Agent.NotFoundError", "AgentNotFoundError"])) throw fail("SESSION_AGENT_REQUIRED", `session '${sessionID}' names the agent '${info.agent}', which does not exist`);
636
+ throw error;
637
+ }
638
+ const rules = [...agent.data.permissions, ...info.permissions ?? []];
639
+ return { agent: info.agent, rules };
640
+ };
641
+ const unavailable = async (server, tool, connected) => {
642
+ const status = (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status;
643
+ const code2 = connection(status) ?? connected;
644
+ if (code2 === "TOOL_UNAVAILABLE") return fail(code2, `OpenCode does not list ${tool} on the connected "${server}" server`);
645
+ if (status === "connected") return fail(code2, `the "${server}" server was reconnecting when ${tool} ran`);
646
+ return fail(code2, `the "${server}" server ${STATUS_TEXT[code2] ?? "is unavailable"}`);
647
+ };
648
+ const denied = (action, error, asked = action) => {
649
+ const reason = upstreamText(record2(error) && typeof error["message"] === "string" ? error["message"] : String(error));
650
+ if (asked === action) return fail("PERMISSION_DENIED", `${action} was refused for this session's agent: ${reason}`);
651
+ return new ScriptError({
652
+ code: "PERMISSION_DENIED",
653
+ what: `${action} needs ${asked}, which was refused for this session's agent: ${reason}`,
654
+ next: `Add an allow rule for ${asked} to the session's agent or to the session, or use a session whose agent allows it.`
655
+ });
656
+ };
657
+ const mcpTarget = (name, envelope2) => {
658
+ const server = serverOf(envelope2.account);
659
+ const action = `${server}_${name}`;
660
+ return {
661
+ action,
662
+ mcp: true,
663
+ async run(call) {
664
+ if (!mcp.managed(server)) throw fail("SERVER_CONFLICT", `the "${server}" server in your config is not the one this plugin manages`);
665
+ const tool = (await ctx.tool.list()).find((entry) => entry.id === action);
666
+ if (!tool) throw await unavailable(server, action, "TOOL_UNAVAILABLE");
667
+ let result;
668
+ try {
669
+ result = await tool.execute(envelope2.input, call);
670
+ } catch (error) {
671
+ if (error instanceof Error && error.message.startsWith(`MCP server "${server}" `)) throw await unavailable(server, action, "CONNECTING");
672
+ throw error;
673
+ }
674
+ return shapeResult(name, normalise(result));
675
+ }
676
+ };
677
+ };
678
+ const ownedTarget = (spec, envelope2) => ({
679
+ action: spec.action,
680
+ mcp: false,
681
+ run: async (call) => await spec.run(envelope2.input, call) ?? null
682
+ });
683
+ const invoke = async (target, raw, context) => {
684
+ const envelope2 = raw;
685
+ let method;
686
+ let id;
687
+ try {
688
+ if (context.signal.aborted) throw fail("CANCELLED", "the call was cancelled");
689
+ method = target(envelope2);
690
+ const { agent, rules } = await session(envelope2.sessionID);
691
+ if (hidden(rules, method.action)) throw fail("PERMISSION_DENIED", `${method.action} is hidden from this session's agent by a deny rule`);
692
+ id = `script:${randomUUID()}`;
693
+ const call = {
694
+ sessionID: envelope2.sessionID,
695
+ agent,
696
+ messageID: MESSAGE_ID,
697
+ id,
698
+ signal: context.signal,
699
+ progress: async () => {
700
+ }
701
+ };
702
+ live.add(call.id);
703
+ try {
704
+ return JSON.parse(JSON.stringify(await method.run(call)));
705
+ } finally {
706
+ live.delete(call.id);
707
+ }
708
+ } catch (error) {
709
+ const failure = refusal(error, method, context.signal, id === void 0 ? void 0 : refused.get(id));
710
+ if (!failure) throw error;
711
+ return context.error("failed", new ScriptError(failure).message, { code: failure.code, next: failure.next });
712
+ } finally {
713
+ if (id !== void 0) refused.delete(id);
714
+ }
715
+ };
716
+ const refusal = (error, method, signal, asked) => {
717
+ if (signal.aborted) return fail("CANCELLED", "the call was cancelled").failure;
718
+ if (error instanceof ScriptError) return error.failure;
719
+ const own = pluginError(error);
720
+ if (own || !method || !toolError2(error)) return own;
721
+ if (error.error !== void 0) return denied(method.action, error, requestAction(error.error) ?? asked).failure;
722
+ if (method.mcp) return fail("UPSTREAM_ERROR", `${method.action} failed: ${upstreamText(error.message)}`).failure;
723
+ return void 0;
724
+ };
725
+ const describe2 = async () => {
726
+ const tools = mcp ? await ctx.tool.list() : [];
727
+ const methods = [];
728
+ if (mcp) {
729
+ const server = serverOf(void 0);
730
+ const managed = mcp.managed(server);
731
+ const status = managed ? (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status : void 0;
732
+ for (const name of KNOWN_READS) {
733
+ const id = `${server}_${name}`;
734
+ const tool = managed ? tools.find((entry) => entry.id === id) : void 0;
735
+ const reason = !managed ? "SERVER_CONFLICT" : tool ? void 0 : connection(status) ?? "TOOL_UNAVAILABLE";
736
+ const input = jsonSchema(tool?.input) ?? ANY_OBJECT;
737
+ const output = jsonSchema(tool?.output);
738
+ methods.push({
739
+ name,
740
+ tool: id,
741
+ kind: "mcp",
742
+ action: id,
743
+ description: tool?.description ?? "",
744
+ available: reason === void 0,
745
+ ...reason ? { reason } : {},
746
+ input,
747
+ output: mcpOutput(output)
748
+ });
749
+ }
750
+ }
751
+ for (const spec of owned) {
752
+ const reason = await spec.available?.();
753
+ methods.push({
754
+ name: spec.name,
755
+ tool: spec.name,
756
+ kind: "plugin",
757
+ action: spec.action,
758
+ description: spec.description,
759
+ available: reason === void 0,
760
+ ...reason === void 0 ? {} : { reason },
761
+ input: spec.input,
762
+ output: spec.output
763
+ });
764
+ }
765
+ return JSON.parse(
766
+ JSON.stringify({
767
+ id: TOOLS_RPC_ID,
768
+ call: `POST /api/rpc/${TOOLS_RPC_ID}/<method> with {"input":{"sessionID":"<session>",${accounts ? `"account":"<account>",` : ""}"input":<the tool's input>}}`,
769
+ location: ctx.location.directory,
770
+ methods
771
+ })
772
+ );
773
+ };
774
+ const envelope = (input, objectInput) => strict(
775
+ object({ sessionID: { type: "string", minLength: 1 }, ...accounts ? { account: { type: "string", minLength: 1 } } : {}, input }, ["sessionID", "input"]),
776
+ (value) => [
777
+ ...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [ENVELOPE_MESSAGES.sessionID],
778
+ ...value["account"] === void 0 || typeof value["account"] === "string" && value["account"] !== "" ? [] : [ENVELOPE_MESSAGES.account],
779
+ ...!("input" in value) ? [ENVELOPE_MESSAGES.inputMissing] : objectInput && !plainObject(value["input"]) ? [ENVELOPE_MESSAGES.inputNotObject] : []
780
+ ]
781
+ );
782
+ const contract = Rpc2.define({
783
+ id: TOOLS_RPC_ID,
784
+ methods: {
785
+ describe: { input: DESCRIBE_INPUT, output: DESCRIPTION },
786
+ ...Object.fromEntries(KNOWN_READS.map((name) => [name, { input: envelope(ANY_OBJECT, true), output: mcpOutput(), errors: { failed: FAILED } }])),
787
+ ...Object.fromEntries(owned.map((spec) => [spec.name, { input: envelope(spec.input, false), output: spec.output, errors: { failed: FAILED } }]))
788
+ },
789
+ events: {}
790
+ });
791
+ const handlers = {
792
+ describe: () => describe2(),
793
+ ...Object.fromEntries(KNOWN_READS.map((name) => [name, (raw, context) => invoke((envelope2) => mcpTarget(name, envelope2), raw, context)])),
794
+ ...Object.fromEntries(owned.map((spec) => [spec.name, (raw, context) => invoke((envelope2) => ownedTarget(spec, envelope2), raw, context)]))
795
+ };
796
+ const registration = await ctx.rpc.register(contract, handlers);
797
+ const hook = await ctx.permission.hook("evaluate", (evaluation) => {
798
+ if (evaluation.source === void 0 || !live.has(evaluation.source.id) || evaluation.effect === "allow") return;
799
+ refused.set(evaluation.source.id, evaluation.action);
800
+ if (evaluation.effect !== "ask") return;
801
+ evaluation.effect = "deny";
802
+ evaluation.message = UNATTENDED;
803
+ });
804
+ return async () => {
805
+ await hook.dispose().catch(() => void 0);
806
+ await registration.dispose().catch(() => void 0);
807
+ };
808
+ }
809
+
358
810
  // src/commands.ts
359
811
  var STATUS_COMMAND = "granola-status";
360
812
  async function registerCommands(ctx, status) {
@@ -681,7 +1133,7 @@ function decode(text3) {
681
1133
  function isMarkup(text3) {
682
1134
  return new RegExp(`<${NAME}[\\s/>]`).test(text3);
683
1135
  }
684
- function parse(text3) {
1136
+ function parse2(text3) {
685
1137
  const root = { name: "", attributes: {}, children: [] };
686
1138
  const stack = [root];
687
1139
  const append = (value) => {
@@ -731,7 +1183,7 @@ var HEADINGS = {
731
1183
  transcript: "Granola meeting transcripts",
732
1184
  both: "Granola meeting notes and transcripts"
733
1185
  };
734
- var UNAVAILABLE = {
1186
+ var UNAVAILABLE2 = {
735
1187
  /** Not one of Granola's reasons: the notes read found no meeting and the transcript read failed. */
736
1188
  meeting_not_found: "Granola returned no meeting for this ID",
737
1189
  transcript_pending: "Granola is still processing it; export again in a few minutes",
@@ -809,7 +1261,7 @@ function blocks(parent) {
809
1261
  return result;
810
1262
  }
811
1263
  function fromMarkup(text3) {
812
- const root = parse(text3);
1264
+ const root = parse2(text3);
813
1265
  const meetings = findAll(root, "meeting");
814
1266
  if (!meetings.length) {
815
1267
  const hints = findAll(root, "not_found_hint");
@@ -901,7 +1353,7 @@ function transcriptEnd(end, maxBytes, empty2) {
901
1353
  case "complete":
902
1354
  return empty2 ? "_The transcript is empty._" : void 0;
903
1355
  case "unavailable":
904
- return `_No transcript: ${UNAVAILABLE[end.reason] ?? oneLine(end.reason, 200)}._`;
1356
+ return `_No transcript: ${UNAVAILABLE2[end.reason] ?? oneLine(end.reason, 200)}._`;
905
1357
  case "size-limit":
906
1358
  return empty2 ? `_Transcript not included: the export reached its ${limit} size limit. Export this meeting on its own to include it._` : `_The transcript stops here: the export reached its ${limit} size limit. Export this meeting on its own to get the rest._`;
907
1359
  case "incomplete":
@@ -930,75 +1382,11 @@ function renderDocument(document) {
930
1382
 
931
1383
  // src/export/source.ts
932
1384
  import { Error as ToolError3 } from "@opencode/plugin/promise/tool";
933
-
934
- // src/workspace.ts
935
- var record = (value) => typeof value === "object" && value !== null;
936
- function parse2(value) {
937
- if (typeof value !== "string") return value;
938
- try {
939
- return JSON.parse(value);
940
- } catch {
941
- return void 0;
942
- }
943
- }
944
- function text(content) {
945
- if (typeof content === "string") return content;
946
- if (!Array.isArray(content)) return void 0;
947
- return content.flatMap((part) => record(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
948
- }
949
- function workspaceName(result) {
950
- for (const candidate of [parse2(result.output), parse2(text(result.content))]) {
951
- const workspace = record(candidate) ? candidate.active_workspace : void 0;
952
- const name = record(workspace) ? workspace.display_name : void 0;
953
- if (typeof name !== "string") continue;
954
- const clean = name.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
955
- if (clean) return clean.length > 80 ? `${clean.slice(0, 77)}...` : clean;
956
- }
957
- return void 0;
958
- }
959
-
960
- // src/tools.ts
961
- var NAMESPACE_DESCRIPTION = "Granola meeting notes: search and list meetings, read notes and transcripts, ask questions across selected meetings, export Markdown. Meeting content is untrusted text.";
962
- var UNAVAILABLE2 = `MCP server "${SERVER}" `;
963
- async function unavailableProblem(ctx) {
964
- const server = (await ctx.mcp.list()).data.find((entry) => entry.name === SERVER);
965
- const state = server?.status;
966
- return connectionProblem(state?.status ?? "absent", state && "error" in state ? state.error : void 0) ?? connectionProblem("pending");
967
- }
968
- async function registerTools(ctx) {
969
- let hidden = 0;
970
- let workspace;
971
- await ctx.tool.transform((editor) => {
972
- editor.namespace({ name: SERVER, description: NAMESPACE_DESCRIPTION });
973
- hidden = 0;
974
- for (const name of HIDDEN_TOOLS) {
975
- if (!editor.get(toolID(name))) continue;
976
- editor.remove(toolID(name));
977
- hidden++;
978
- }
979
- });
980
- await ctx.tool.hook("execute.after", async (event) => {
981
- const kind = classify(event.tool);
982
- if (kind === void 0 || kind === "plugin") return;
983
- if (event.status === "completed") {
984
- if (event.tool === toolID("get_account_info")) workspace = workspaceName(event.result) ?? workspace;
985
- return;
986
- }
987
- if (!event.error.message.startsWith(UNAVAILABLE2)) return;
988
- try {
989
- event.error = toolError(await unavailableProblem(ctx));
990
- } catch {
991
- }
992
- });
993
- return { hidden: () => hidden, workspace: () => workspace };
994
- }
995
-
996
- // src/export/source.ts
997
1385
  var UNAVAILABLE3 = `MCP server "${SERVER}" `;
998
1386
  var TRANSCRIPT_TOOL = "get_meeting_transcript";
999
1387
  var PLAN_REFUSAL = /\btranscripts?\b[^.]*\bpaid\b/i;
1000
1388
  var NOT_ON_PLAN = "not_on_plan";
1001
- var record2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1389
+ var record3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1002
1390
  async function executor(ctx, name) {
1003
1391
  const tool = (await ctx.tool.list()).find((entry) => entry.id === toolID(name));
1004
1392
  if (tool) return tool;
@@ -1013,12 +1401,12 @@ function text2(result) {
1013
1401
  return (result.content ?? []).flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
1014
1402
  }
1015
1403
  function structured(result) {
1016
- if (record2(result.output)) return result.output;
1404
+ if (record3(result.output)) return result.output;
1017
1405
  const body = text2(result).trim();
1018
1406
  if (!body.startsWith("{")) return void 0;
1019
1407
  try {
1020
1408
  const value = JSON.parse(body);
1021
- return record2(value) ? value : void 0;
1409
+ return record3(value) ? value : void 0;
1022
1410
  } catch {
1023
1411
  return void 0;
1024
1412
  }
@@ -1027,7 +1415,7 @@ function readPage(result) {
1027
1415
  const value = structured(result);
1028
1416
  if (!value || !("transcript" in value)) return { text: text2(result), endsMidUtterance: false };
1029
1417
  const transcript = typeof value.transcript === "string" ? value.transcript : null;
1030
- const unavailable = record2(value.transcript_unavailable) ? value.transcript_unavailable.reason : value.transcript_unavailable;
1418
+ const unavailable = record3(value.transcript_unavailable) ? value.transcript_unavailable.reason : value.transcript_unavailable;
1031
1419
  const next = value.truncated === true && Number.isSafeInteger(value.next_offset) ? value.next_offset : void 0;
1032
1420
  return {
1033
1421
  text: transcript,
@@ -1356,14 +1744,11 @@ function satisfies(version, range) {
1356
1744
  return order === 0;
1357
1745
  });
1358
1746
  }
1359
- function checkHost(version, tested, allowUntested) {
1360
- if (satisfies(version, tested)) return { version, tested };
1361
- if (!allowUntested) throw new GranolaError(hostUntested(tested, version));
1362
- return {
1363
- version,
1364
- tested,
1365
- warning: `outside the tested line ${tested}; it runs because allowUntestedHost is true`
1366
- };
1747
+ function checkHost(version, range, allowUntested) {
1748
+ const tested = TESTED_HOSTS.includes(version);
1749
+ if (satisfies(version, range)) return { version, range, tested };
1750
+ if (!allowUntested) throw new GranolaError(hostUntested(range, version));
1751
+ return { version, range, tested, warning: `outside the supported range ${range}; it runs because allowUntestedHost is true` };
1367
1752
  }
1368
1753
 
1369
1754
  // src/integration.ts
@@ -1397,13 +1782,13 @@ async function registerIntegration(ctx, conflict) {
1397
1782
  const id = server?.integrationID;
1398
1783
  if (!server || !id || stopped.signal.aborted) return;
1399
1784
  if (id !== attached || (await ctx.integration.get({ integrationID: id })).data.name !== DISPLAY_NAME) await attach(id);
1400
- const connection = await ctx.integration.connection.active(id);
1401
- if (connection?.type !== "credential") return;
1785
+ const connection2 = await ctx.integration.connection.active(id);
1786
+ if (connection2?.type !== "credential") return;
1402
1787
  const state = server.status.status;
1403
- if (state === "needs_auth" && connection.status?.status !== "needs_auth") {
1404
- await ctx.integration.connection.status({ integrationID: id, connection, status: { status: "needs_auth", message: SIGN_IN_MESSAGE } });
1405
- } else if (state === "connected" && connection.status) {
1406
- await ctx.integration.connection.status({ integrationID: id, connection, status: void 0 });
1788
+ if (state === "needs_auth" && connection2.status?.status !== "needs_auth") {
1789
+ await ctx.integration.connection.status({ integrationID: id, connection: connection2, status: { status: "needs_auth", message: SIGN_IN_MESSAGE } });
1790
+ } else if (state === "connected" && connection2.status) {
1791
+ await ctx.integration.connection.status({ integrationID: id, connection: connection2, status: void 0 });
1407
1792
  }
1408
1793
  };
1409
1794
  let checking;
@@ -1503,7 +1888,15 @@ function parseOptions(raw, home = homedir()) {
1503
1888
 
1504
1889
  // src/rpc/register.ts
1505
1890
  async function registerRpc(ctx, status) {
1506
- await ctx.rpc.register(Granola, { status: () => status.read() });
1891
+ await ctx.rpc.register(Granola, {
1892
+ status: async (_input, context) => {
1893
+ try {
1894
+ return await status.read();
1895
+ } catch {
1896
+ return context.error("unavailable", describe(STATUS_UNAVAILABLE), { code: STATUS_UNAVAILABLE.code, next: STATUS_UNAVAILABLE.next });
1897
+ }
1898
+ }
1899
+ });
1507
1900
  }
1508
1901
 
1509
1902
  // src/server.ts
@@ -1560,10 +1953,10 @@ async function registerSkills(ctx, skills = readSkills()) {
1560
1953
  }
1561
1954
 
1562
1955
  // src/setup.ts
1563
- async function setup(ctx) {
1956
+ async function setup(ctx, toolsRpc) {
1564
1957
  const info = readPackageInfo();
1565
1958
  const options = parseOptions(ctx.options);
1566
- const host = checkHost(ctx.app.version, info.testedHost, options.allowUntestedHost);
1959
+ const host = checkHost(ctx.app.version, info.hostRange, options.allowUntestedHost);
1567
1960
  const server = await registerServer(ctx);
1568
1961
  const integration = await registerIntegration(ctx, server.conflict);
1569
1962
  await registerPermissions(ctx);
@@ -1573,7 +1966,7 @@ async function setup(ctx) {
1573
1966
  workspace: tools?.workspace(),
1574
1967
  conflict: server.conflict(),
1575
1968
  exportDirectory: options.exportDirectory,
1576
- pluginVersion: info.version,
1969
+ version: info.version,
1577
1970
  host
1578
1971
  }));
1579
1972
  tools = await registerTools(ctx);
@@ -1581,11 +1974,16 @@ async function setup(ctx) {
1581
1974
  await registerSkills(ctx);
1582
1975
  await registerExport(ctx, options);
1583
1976
  await registerRpc(ctx, status);
1584
- return () => integration.dispose();
1977
+ const stopTools = await toolsRpc({ mcp: { server: SERVER, managed: () => server.conflict() === void 0 } });
1978
+ return async () => {
1979
+ tools?.dispose();
1980
+ await stopTools();
1981
+ await integration.dispose();
1982
+ };
1585
1983
  }
1586
1984
 
1587
1985
  // src/index.ts
1588
- var src_default = Plugin.define({ id: PLUGIN_ID, setup });
1986
+ var src_default = Plugin.define({ id: PLUGIN_ID, setup: (ctx) => setup(ctx, (options) => registerToolsRpc(ctx, options)) });
1589
1987
  export {
1590
1988
  src_default as default
1591
1989
  };