@joe-broadhead/opencode-granola 0.0.1 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.0.2] - 2026-11-04
6
+
7
+ ### Added
8
+
9
+ - OpenCode 2.2.5 is a tested release, next to 2.2.3 and 2.2.4.
10
+ - The status RPC (`granola.meetings` `status`) has `version`, the plugin's version, and `host.range` and
11
+ `host.tested`. A status it cannot read is now the RPC error `unavailable` with `STATUS_UNAVAILABLE` and its next
12
+ step, and the `/granola` dialog says so.
13
+
14
+ ### Changed
15
+
16
+ - The status RPC uses the field names every plugin of this family shares. Scripts that read it need these changes:
17
+ - `next` is now `nextStep`;
18
+ - `pluginVersion` is now `version`;
19
+ - `hostVersion` is now `host.version`, which is `null` when OpenCode reports no version;
20
+ - `state` uses the shared states: `pending` is now `connecting`, `needs_auth` is now `sign_in_required`, `absent`
21
+ is now `not_configured`, and the user's own `granola` server for another URL is now `conflict` (it was that
22
+ server's connection state, next to `conflict`);
23
+ - `problem` no longer has `next`: the one next step is `nextStep`;
24
+ - `tools.visible` counts every Granola tool the model can call, unknown ones included.
25
+ - On a release inside `>=2.2.3 <2.3.0` other than 2.2.3, 2.2.4 and 2.2.5, Granola still sets everything up, and only
26
+ the `OpenCode` row calls the version untested. The row reads, for example,
27
+ `2.2.5 (tested; supported range >=2.2.3 <2.3.0), plugin <version>`. On a host outside the range that runs because
28
+ of `allowUntestedHost`, the next step, when nothing else is needed, says to use a tested release if Granola
29
+ misbehaves.
30
+ - Status always shows the `Agents without default rules` row, with `none` when no agent needs the fix. It lists an
31
+ agent only when its own rules would let a Granola tool this plugin does not know run without asking; an agent
32
+ whose rules already ask or deny for every Granola tool is not listed.
33
+ - The sign-in next step says to press `o` or Enter to open the sign-in page. A disabled server's next step offers
34
+ `/mcps` first. A missing server's next step points at an organisation policy (`mcp.use`) or another plugin that
35
+ removes it, not at the plugins list.
36
+ - The tools RPC's `describe` takes no input: anything but `{}` or nothing now gets `describe takes no input`. An
37
+ envelope that is not an object gets `the envelope must be an object, such as {…}`.
38
+ - A refused permission check now names the action that was refused, when it is not the method's own.
39
+
40
+ ### Fixed
41
+
42
+ - A failed connection's reason no longer ends in doubled punctuation, as in `…access the url?.`.
43
+ - The README's sign-in check quoted a status line that does not exist. It now says the `Connection` row shows
44
+ `connected` and the `Tools` row how many Granola tools are available.
45
+ - The README said Explore is left unchanged. Explore and Title also get the block; their own later deny rules still
46
+ keep Granola's tools out.
47
+ - The README's Uninstall now says why to sign out first: once the plugin is removed, `opencode mcp logout granola`
48
+ answers "MCP server not found" and the sign-in stays in OpenCode's credential store. It signs out, checks with
49
+ `opencode auth list`, then removes the plugin, reloads, and checks with `opencode mcp list`.
50
+ - The README now says what to do after `opencode plugin add` (run `opencode reload`, then `/granola-status`), how to
51
+ update the plugin, and, under Troubleshooting, that Granola's free plan returns only the last 30 days of meetings.
52
+ - `docs/scripting.md` said `opencode api` runs in the directory you call it from. It runs in the OpenCode service's
53
+ default location, your home directory; the doc now says how to target a project. It also says that `opencode api`
54
+ exits with status 1 for an RPC error, and that a result with only text has that text as its `output`.
55
+ - The `granola` skill now names the `folder_id` filter of `search_meetings` and `list_meetings`.
56
+ - A script's call that found Granola disconnected, while Granola reads connected again by the time the plugin looks,
57
+ now gets `CONNECTING` and "Retry in a few seconds.", as the model does. It got `CONNECTION_FAILED` and a reconnect
58
+ step before.
59
+
5
60
  ## [0.0.1] - 2026-11-03
6
61
 
7
62
  ### Added
package/README.md CHANGED
@@ -7,7 +7,8 @@ Unofficial; not affiliated with Granola.
7
7
 
8
8
  ## Requirements
9
9
 
10
- - OpenCode `>=2.2.3 <2.3.0`, tested on 2.2.3 and 2.2.4. On another version the plugin refuses to start; see
10
+ - OpenCode `>=2.2.3 <2.3.0`, tested on 2.2.3, 2.2.4 and 2.2.5. Another release inside that range runs, and status
11
+ calls it untested. Outside the range the plugin does not start, unless you set `allowUntestedHost`; see
11
12
  [Troubleshooting](#troubleshooting).
12
13
  - macOS or Linux.
13
14
  - A Granola account. Granola's free plan includes the last 30 days of meeting history; transcripts and folders are
@@ -19,13 +20,19 @@ Unofficial; not affiliated with Granola.
19
20
  opencode plugin add @joe-broadhead/opencode-granola
20
21
  ```
21
22
 
22
- This adds the plugin to your global OpenCode config. Restart OpenCode afterwards.
23
+ This adds the plugin to your global OpenCode config. Run `opencode reload`, or restart OpenCode. Then run
24
+ `/granola-status`: it shows `sign-in required` until you sign in.
25
+
26
+ **Update** with `opencode plugin update @joe-broadhead/opencode-granola`, then run `opencode reload`. An entry pinned to
27
+ a version, such as `@joe-broadhead/opencode-granola@0.0.1`, does not update: remove the `@version` from its `plugins`
28
+ entry first.
23
29
 
24
30
  ## Sign in
25
31
 
26
32
  1. Run `/connect` and choose **Granola**, listed under MCP.
27
33
  2. Press `o` or Enter to open Granola's sign-in page in your browser, then approve there.
28
- 3. Run `/granola-status`. It shows `Granola: connected` and the number of Granola tools available.
34
+ 3. Run `/granola-status`. Its `Connection` row shows `connected`, and its `Tools` row shows how many Granola tools
35
+ are available.
29
36
 
30
37
  On the CLI, `opencode mcp auth granola` opens the sign-in page automatically. `/mcps` works too. There is
31
38
  nothing to configure first: Granola registers OpenCode's sign-in itself.
@@ -84,8 +91,9 @@ See [Scripting](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/docs
84
91
  | Hidden | `sign_out` | removed; use `opencode mcp logout granola` |
85
92
 
86
93
  The plugin inserts these rules into each agent, directly after the agent's base "allow everything" rule. Your own
87
- `permissions` and your saved "Always allow" answers override them, and a `deny` you configure always wins. Agents
88
- without the base rule, such as Explore, are left unchanged, and Plan keeps its restrictions.
94
+ `permissions` and your saved "Always allow" answers override them, and a `deny` you configure always wins. Explore
95
+ and Title also get the block, but their own later deny rules still keep Granola tools out, and Plan keeps its
96
+ restrictions. An agent without a base "allow everything" rule gets no rules.
89
97
 
90
98
  For example, to let one write run without asking:
91
99
 
@@ -99,7 +107,8 @@ For example, to let one write run without asking:
99
107
 
100
108
  Agents defined only in your own config are created after plugins start, so they miss the default rules, and Granola's
101
109
  writes would run there without asking. Status names each such agent and gives the block to paste first in that agent's
102
- own `permissions`. Do not put the block in the top-level `permissions`, which would loosen Explore.
110
+ own `permissions`. An agent whose own rules already ask or deny for every Granola tool needs nothing and is not named.
111
+ Do not put the block in the top-level `permissions`, which would loosen Explore.
103
112
 
104
113
  In `opencode run` and other runs where nobody can answer, a call that would ask is refused; reads still work.
105
114
 
@@ -132,26 +141,33 @@ Each member then signs in once with `/connect` → Granola. A managed `mcp.serve
132
141
 
133
142
  ## Troubleshooting
134
143
 
135
- Each connection state, as `/granola-status` shows it:
144
+ Run `/granola-status` (or `/granola` in the terminal UI) first. It shows the rows `Connection`, `Account`, `Access`,
145
+ `Export folder`, `Tools`, `Agents without default rules` (`none` when no agent needs the fix), `OpenCode` and exactly
146
+ one `Next step`. Each connection state:
136
147
 
137
148
  | Status | Code | What to do |
138
149
  | --- | --- | --- |
139
- | not configured | `NOT_REGISTERED` | 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. |
150
+ | not configured | `NOT_REGISTERED` | 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. |
140
151
  | connecting | `CONNECTING` | Retry in a few seconds. |
141
- | sign-in required | `AUTH_REQUIRED` | Run `/connect` and choose Granola, or run `opencode mcp auth granola` in a terminal. |
152
+ | sign-in required | `AUTH_REQUIRED` | 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. |
142
153
  | failed | `CONNECTION_FAILED` | Reconnect Granola from `/mcps`, and if it keeps failing, run `/connect` and choose Granola to sign in again. |
143
- | disabled | `DISABLED` | Remove `disabled: true` from `mcp.servers.granola` in your OpenCode config. |
154
+ | disabled | `DISABLED` | Enable `granola` in `/mcps`, or remove `"disabled": true` from `mcp.servers.granola` in your OpenCode config. |
144
155
  | conflict | none | Remove `mcp.servers.granola` from your OpenCode config to use Granola's official server. |
156
+ | connected | none | Ready. The next step reads `Nothing to do.`, or names agents without default rules. |
145
157
 
146
158
  **`MCP server not found: granola` from `opencode mcp auth granola`, right after OpenCode starts.** Plugins are still
147
159
  loading for a few seconds. Run it again, or use `/connect`, which updates live.
148
160
 
161
+ **Older meetings are missing.** On Granola's free plan, Granola's server returns only the meetings of the last 30 days.
162
+
149
163
  **A tool says your Granola plan does not include it, or `TOOL_UNAVAILABLE`.** Granola's free plan has no
150
- transcripts or folders, and covers the last 30 days of meetings. Older meetings are not found.
164
+ transcripts or folders.
151
165
 
152
- **`HOST_UNTESTED`.** The plugin refuses to start on an OpenCode version outside `>=2.2.3 <2.3.0`, including
153
- development builds. Use a tested OpenCode version, or set the plugin option `allowUntestedHost: true` to run anyway;
154
- status then shows `host untested`.
166
+ **`HOST_UNTESTED`.** The plugin does not start on an OpenCode version outside `>=2.2.3 <2.3.0`, including
167
+ development builds. Use OpenCode `>=2.2.3 <2.3.0`, or set the plugin option `allowUntestedHost: true` to run anyway;
168
+ the `OpenCode` row then says `host untested`, and when nothing else is needed the next step says to use a tested
169
+ release if Granola misbehaves. A release inside the range other than 2.2.3, 2.2.4 and 2.2.5 runs normally: only the
170
+ `OpenCode` row calls it untested.
155
171
 
156
172
  **`OPTIONS_INVALID`.** A plugin option has an unknown name or a wrong type. The message names the option.
157
173
 
@@ -168,12 +184,16 @@ supports one Granola account.
168
184
 
169
185
  ## Uninstall
170
186
 
171
- ```sh
172
- opencode mcp logout granola
173
- opencode plugin remove @joe-broadhead/opencode-granola
174
- ```
187
+ Sign out while the plugin is still installed: once it is removed, OpenCode no longer knows the `granola` server, so
188
+ it cannot remove its sign-in, and `opencode auth list` stops showing it.
189
+
190
+ 1. Sign out of Granola: `opencode mcp logout granola`.
191
+ 2. Check: `opencode auth list` no longer lists **Granola**.
192
+ 3. Remove the plugin: `opencode plugin remove @joe-broadhead/opencode-granola`, then `opencode reload`.
193
+ `opencode mcp list` no longer lists `granola`.
194
+ 4. Remove any Granola rules you copied into your agents' own `permissions`.
175
195
 
176
- Restart OpenCode afterwards. Files you exported stay in your export folder.
196
+ Files you exported stay in your export folder.
177
197
 
178
198
  ## Links
179
199
 
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { Rpc as Rpc2 } from "@opencode/plugin/rpc";
7
7
 
8
8
  // src/errors.ts
9
9
  import { Error as ToolError } from "@opencode/plugin/promise/tool";
10
- 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).";
11
11
  function describe(problem) {
12
12
  return `${problem.code}: ${problem.message} Next step: ${problem.next}`;
13
13
  }
@@ -29,6 +29,7 @@ function shortReason(text3) {
29
29
  if (!reason) return "no reason given";
30
30
  return reason.length > 160 ? `${reason.slice(0, 157)}...` : reason;
31
31
  }
32
+ var sentence = (reason) => /[.?!]$/.test(reason) ? reason : `${reason}.`;
32
33
  function connectionProblem(state, error) {
33
34
  switch (state) {
34
35
  case "connected":
@@ -37,7 +38,7 @@ function connectionProblem(state, error) {
37
38
  return {
38
39
  code: "NOT_REGISTERED",
39
40
  message: "The Granola connection isn't registered yet.",
40
- 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."
41
42
  };
42
43
  case "pending":
43
44
  return { code: "CONNECTING", message: "Granola is still connecting.", next: "Retry in a few seconds." };
@@ -46,14 +47,14 @@ function connectionProblem(state, error) {
46
47
  case "failed":
47
48
  return {
48
49
  code: "CONNECTION_FAILED",
49
- message: `Granola connection failed: ${shortReason(error)}.`,
50
+ message: `Granola connection failed: ${sentence(shortReason(error))}`,
50
51
  next: "Reconnect Granola from /mcps, and if it keeps failing, run /connect and choose Granola to sign in again."
51
52
  };
52
53
  case "disabled":
53
54
  return {
54
55
  code: "DISABLED",
55
- message: "Granola is disabled in your OpenCode config.",
56
- 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.'
57
58
  };
58
59
  }
59
60
  }
@@ -76,11 +77,11 @@ function invalidOption(key, problem) {
76
77
  next: "Fix the Granola plugin options in your OpenCode config, then reload OpenCode."
77
78
  };
78
79
  }
79
- function hostUntested(tested, found) {
80
+ function hostUntested(range, found) {
80
81
  return {
81
82
  code: "HOST_UNTESTED",
82
- message: `The Granola plugin is tested with OpenCode ${tested}, found ${found}.`,
83
- 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.`
84
85
  };
85
86
  }
86
87
  function skillsInvalid(path3) {
@@ -142,7 +143,7 @@ function readPackageInfo(url = new URL("../package.json", import.meta.url)) {
142
143
  if (!record4(raw) || typeof raw.version !== "string" || !record4(raw.opencode) || typeof raw.opencode.host !== "string") {
143
144
  throw new GranolaError(packageInvalid("needs a version and an opencode.host range"));
144
145
  }
145
- return { version: raw.version, testedHost: raw.opencode.host };
146
+ return { version: raw.version, hostRange: raw.opencode.host };
146
147
  }
147
148
 
148
149
  // src/permissions.ts
@@ -169,15 +170,12 @@ function matches(input, pattern) {
169
170
  const source = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
170
171
  return new RegExp(`^${source}$`, "s").test(input);
171
172
  }
172
- function winner(action, rules) {
173
- 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";
174
175
  }
175
- var SIDE_EFFECTS = [...WRITE_TOOLS.map(toolID), toolID("unknown_tool")];
176
+ var UNKNOWN_TOOL = toolID("unknown_tool");
176
177
  function unguardedAgents(agents) {
177
- return agents.filter((agent) => !hasDefaults(agent.permissions)).filter((agent) => SIDE_EFFECTS.some((action) => {
178
- const rule = winner(action, agent.permissions);
179
- return rule?.effect === "allow" && !rule.action.startsWith("granola");
180
- })).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);
181
179
  }
182
180
  async function registerPermissions(ctx) {
183
181
  await ctx.agent.transform((editor) => {
@@ -187,18 +185,34 @@ async function registerPermissions(ctx) {
187
185
 
188
186
  // src/rpc/contract.ts
189
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
+ };
190
197
  var STATUS_SCHEMA = {
191
198
  type: "object",
192
199
  additionalProperties: false,
193
- required: ["state", "tools", "unguardedAgents", "exportDirectory", "pluginVersion", "hostVersion", "next"],
200
+ required: ["version", "host", "state", "tools", "unguardedAgents", "exportDirectory", "nextStep"],
194
201
  properties: {
195
- 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 },
196
210
  title: { type: "string" },
197
211
  problem: {
198
212
  type: "object",
199
213
  additionalProperties: false,
200
- required: ["code", "message", "next"],
201
- properties: { code: { type: "string" }, message: { type: "string" }, next: { type: "string" } }
214
+ required: ["code", "message"],
215
+ properties: { code: { type: "string" }, message: { type: "string" } }
202
216
  },
203
217
  conflict: { type: "string" },
204
218
  tools: {
@@ -228,31 +242,43 @@ var STATUS_SCHEMA = {
228
242
  }
229
243
  },
230
244
  exportDirectory: { type: "string" },
231
- pluginVersion: { type: "string" },
232
- hostVersion: { type: "string" },
233
245
  hostWarning: { type: "string" },
234
- next: { type: "string" }
246
+ nextStep: { type: "string" }
235
247
  }
236
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
+ };
237
255
  var STATE_LABELS = {
238
256
  connected: "connected",
239
- pending: "connecting",
240
- needs_auth: "sign-in required",
257
+ connecting: "connecting",
258
+ sign_in_required: "sign-in required",
241
259
  failed: "failed",
242
260
  disabled: "disabled",
243
- absent: "not configured"
261
+ not_configured: "not configured",
262
+ conflict: "conflict"
244
263
  };
245
264
  var TONES = {
246
265
  connected: "ok",
247
- pending: "info",
248
- needs_auth: "warning",
266
+ connecting: "info",
267
+ sign_in_required: "warning",
249
268
  failed: "error",
250
269
  disabled: "info",
251
- absent: "warning"
270
+ not_configured: "warning",
271
+ conflict: "warning"
252
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
+ }
253
279
  function statusRows(status) {
254
280
  const connected = status.state === "connected";
255
- const connection2 = 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})` : ""}`;
256
282
  const tools = [`${status.tools.visible} available`];
257
283
  if (status.tools.hidden) tools.push(`${status.tools.hidden} hidden`);
258
284
  if (status.tools.unknown) {
@@ -261,18 +287,18 @@ function statusRows(status) {
261
287
  }
262
288
  const agents = status.unguardedAgents;
263
289
  return [
264
- { label: "Connection", text: connection2, tone: status.conflict ? "warning" : TONES[status.state] },
290
+ { label: "Connection", text: connection2, tone: TONES[status.state] },
265
291
  { label: "Account", text: !connected ? "unknown until Granola connects" : status.workspace ? `workspace ${status.workspace}` : "workspace unknown until the agent reads your Granola account" },
266
292
  { label: "Access", text: "reads run without asking; changes, new tools and exports ask first" },
267
293
  { label: "Export folder", text: status.exportDirectory },
268
294
  { label: "Tools", text: connected ? tools.join(", ") : "none until Granola connects" },
269
- ...agents.length ? [{
295
+ agents.length ? {
270
296
  label: "Agents without default rules",
271
- text: `${agents.join(", ")}: Granola writes run there without asking`,
297
+ text: `${agents.join(", ")}: Granola changes and new tools may run there without asking`,
272
298
  tone: "warning",
273
299
  block: permissionsSnippet(agents, status.permissionsBlock ?? [])
274
- }] : [],
275
- 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)
276
302
  ];
277
303
  }
278
304
  function permissionsSnippet(agents, block2) {
@@ -294,34 +320,41 @@ var Granola = Rpc.define({
294
320
  methods: {
295
321
  status: {
296
322
  input: { type: "object", properties: {}, additionalProperties: false },
297
- output: STATUS_SCHEMA
323
+ output: STATUS_SCHEMA,
324
+ errors: { unavailable: STATUS_ERROR_SCHEMA }
298
325
  }
299
326
  },
300
327
  events: {}
301
328
  });
302
329
 
330
+ // src/hosts.ts
331
+ var TESTED_HOSTS = ["2.2.3", "2.2.4", "2.2.5"];
332
+
303
333
  // src/status.ts
304
334
  var LISTED_UNKNOWN = 20;
305
335
  var SAFE_NAME = /^[a-z][a-z0-9_]{0,63}$/;
306
336
  var CONFLICT_NEXT = "Remove mcp.servers.granola from your OpenCode config to use Granola's official server.";
307
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)}.`;
308
339
  function agentFix(agents) {
309
340
  const where = agents.map((agent) => `agents.${agent}.permissions`).join(", ");
310
341
  return `Put the Granola block first in that agent's own permissions (${where}), followed by your top-level rules for granola_ actions.`;
311
342
  }
312
343
  function buildStatus(inputs) {
313
344
  const server = inputs.servers.find((entry) => entry.name === SERVER);
314
- const state = server?.status.status ?? "absent";
315
- 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);
316
347
  const classes = inputs.tools.map((tool) => ({ id: tool.id, kind: classify(tool.id) }));
317
348
  const unknown = classes.filter((tool) => tool.kind === "unknown").map((tool) => tool.id.slice(SERVER.length + 1));
318
- 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;
319
350
  const unguarded = unguardedAgents(inputs.agents);
320
- 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;
321
352
  return {
322
- state,
323
- ...state === "connected" && server?.title ? { title: server.title } : {},
324
- ...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 } } : {},
325
358
  ...inputs.conflict ? { conflict: inputs.conflict } : {},
326
359
  tools: {
327
360
  visible,
@@ -333,10 +366,8 @@ function buildStatus(inputs) {
333
366
  unguardedAgents: unguarded,
334
367
  ...unguarded.length ? { permissionsBlock: DEFAULT_RULES.map((rule) => ({ ...rule })) } : {},
335
368
  exportDirectory: inputs.exportDirectory,
336
- pluginVersion: inputs.pluginVersion,
337
- hostVersion: inputs.host.version,
338
369
  ...inputs.host.warning ? { hostWarning: inputs.host.warning } : {},
339
- next
370
+ nextStep
340
371
  };
341
372
  }
342
373
  function formatStatus(status) {
@@ -345,7 +376,7 @@ function formatStatus(status) {
345
376
  lines.push(`${row.label}: ${row.text}`);
346
377
  for (const line of row.block ?? []) lines.push(` ${line}`);
347
378
  }
348
- lines.push(`Next step: ${status.next}`);
379
+ lines.push(`Next step: ${status.nextStep}`);
349
380
  return lines.join("\n");
350
381
  }
351
382
  function createStatus(ctx, fixed) {
@@ -486,14 +517,27 @@ var DESCRIPTION = object({ id: STRING, call: STRING, location: STRING, methods:
486
517
  "methods"
487
518
  ]);
488
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
+ );
489
534
  function strict(schema, issues) {
490
535
  const keys = Object.keys(schema["properties"]);
491
- const validate = (value) => {
492
- if (!plainObject(value)) return { issues: [{ message: "Expected an object" }] };
493
- const found = [...Object.keys(value).filter((key) => !keys.includes(key)).map((key) => `"${key}" is not allowed`), ...issues(value)];
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)];
494
539
  return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
495
- };
496
- return Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "tools-rpc", validate }, enumerable: false });
540
+ });
497
541
  }
498
542
  var ScriptError = class extends Error {
499
543
  failure;
@@ -510,6 +554,10 @@ function notFound(error, tags) {
510
554
  return typeof tag === "string" && tags.includes(tag) || error["status"] === 404 || error["statusCode"] === 404;
511
555
  }
512
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
+ };
513
561
  var wildcard = (pattern, value) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(value);
514
562
  var hidden = (rules, action) => rules.findLast((rule) => wildcard(rule.action, action) && wildcard(rule.resource, "*"))?.effect === "deny";
515
563
  var SECRETS = [
@@ -565,6 +613,7 @@ async function registerToolsRpc(ctx, options) {
565
613
  const duplicate = names.find((name, index) => name === "describe" || names.indexOf(name) !== index);
566
614
  if (duplicate) throw new Error(`registerToolsRpc: the method name "${duplicate}" is taken`);
567
615
  const live = /* @__PURE__ */ new Set();
616
+ const refused = /* @__PURE__ */ new Map();
568
617
  const serverOf = (account) => {
569
618
  const server = typeof mcp.server === "function" ? mcp.server(account) : mcp.server;
570
619
  if (server !== void 0) return server;
@@ -593,11 +642,17 @@ async function registerToolsRpc(ctx, options) {
593
642
  const status = (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status;
594
643
  const code2 = connection(status) ?? connected;
595
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`);
596
646
  return fail(code2, `the "${server}" server ${STATUS_TEXT[code2] ?? "is unavailable"}`);
597
647
  };
598
- const denied = (action, error) => {
599
- const reason = record2(error) && typeof error["message"] === "string" ? error["message"] : String(error);
600
- return fail("PERMISSION_DENIED", `${action} was refused for this session's agent: ${upstreamText(reason)}`);
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
+ });
601
656
  };
602
657
  const mcpTarget = (name, envelope2) => {
603
658
  const server = serverOf(envelope2.account);
@@ -613,7 +668,7 @@ async function registerToolsRpc(ctx, options) {
613
668
  try {
614
669
  result = await tool.execute(envelope2.input, call);
615
670
  } catch (error) {
616
- if (error instanceof Error && error.message.startsWith(`MCP server "${server}" `)) throw await unavailable(server, action, "CONNECTION_FAILED");
671
+ if (error instanceof Error && error.message.startsWith(`MCP server "${server}" `)) throw await unavailable(server, action, "CONNECTING");
617
672
  throw error;
618
673
  }
619
674
  return shapeResult(name, normalise(result));
@@ -628,16 +683,18 @@ async function registerToolsRpc(ctx, options) {
628
683
  const invoke = async (target, raw, context) => {
629
684
  const envelope2 = raw;
630
685
  let method;
686
+ let id;
631
687
  try {
632
688
  if (context.signal.aborted) throw fail("CANCELLED", "the call was cancelled");
633
689
  method = target(envelope2);
634
690
  const { agent, rules } = await session(envelope2.sessionID);
635
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()}`;
636
693
  const call = {
637
694
  sessionID: envelope2.sessionID,
638
695
  agent,
639
696
  messageID: MESSAGE_ID,
640
- id: `script:${randomUUID()}`,
697
+ id,
641
698
  signal: context.signal,
642
699
  progress: async () => {
643
700
  }
@@ -649,17 +706,19 @@ async function registerToolsRpc(ctx, options) {
649
706
  live.delete(call.id);
650
707
  }
651
708
  } catch (error) {
652
- const failure = refusal(error, method, context.signal);
709
+ const failure = refusal(error, method, context.signal, id === void 0 ? void 0 : refused.get(id));
653
710
  if (!failure) throw error;
654
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);
655
714
  }
656
715
  };
657
- const refusal = (error, method, signal) => {
716
+ const refusal = (error, method, signal, asked) => {
658
717
  if (signal.aborted) return fail("CANCELLED", "the call was cancelled").failure;
659
718
  if (error instanceof ScriptError) return error.failure;
660
719
  const own = pluginError(error);
661
720
  if (own || !method || !toolError2(error)) return own;
662
- if (error.error !== void 0) return denied(method.action, error).failure;
721
+ if (error.error !== void 0) return denied(method.action, error, requestAction(error.error) ?? asked).failure;
663
722
  if (method.mcp) return fail("UPSTREAM_ERROR", `${method.action} failed: ${upstreamText(error.message)}`).failure;
664
723
  return void 0;
665
724
  };
@@ -689,8 +748,20 @@ async function registerToolsRpc(ctx, options) {
689
748
  });
690
749
  }
691
750
  }
692
- for (const spec of owned)
693
- methods.push({ name: spec.name, tool: spec.name, kind: "plugin", action: spec.action, description: spec.description, available: true, input: spec.input, output: spec.output });
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
+ }
694
765
  return JSON.parse(
695
766
  JSON.stringify({
696
767
  id: TOOLS_RPC_ID,
@@ -703,15 +774,15 @@ async function registerToolsRpc(ctx, options) {
703
774
  const envelope = (input, objectInput) => strict(
704
775
  object({ sessionID: { type: "string", minLength: 1 }, ...accounts ? { account: { type: "string", minLength: 1 } } : {}, input }, ["sessionID", "input"]),
705
776
  (value) => [
706
- ...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [`"sessionID" must be a non-empty string`],
707
- ...value["account"] === void 0 || typeof value["account"] === "string" && value["account"] !== "" ? [] : [`"account" must be a non-empty string`],
708
- ...!("input" in value) ? [`"input" is missing`] : objectInput && !plainObject(value["input"]) ? [`"input" must be an object`] : []
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] : []
709
780
  ]
710
781
  );
711
782
  const contract = Rpc2.define({
712
783
  id: TOOLS_RPC_ID,
713
784
  methods: {
714
- describe: { input: NO_INPUT, output: DESCRIPTION },
785
+ describe: { input: DESCRIBE_INPUT, output: DESCRIPTION },
715
786
  ...Object.fromEntries(KNOWN_READS.map((name) => [name, { input: envelope(ANY_OBJECT, true), output: mcpOutput(), errors: { failed: FAILED } }])),
716
787
  ...Object.fromEntries(owned.map((spec) => [spec.name, { input: envelope(spec.input, false), output: spec.output, errors: { failed: FAILED } }]))
717
788
  },
@@ -724,7 +795,9 @@ async function registerToolsRpc(ctx, options) {
724
795
  };
725
796
  const registration = await ctx.rpc.register(contract, handlers);
726
797
  const hook = await ctx.permission.hook("evaluate", (evaluation) => {
727
- if (evaluation.effect !== "ask" || evaluation.source === void 0 || !live.has(evaluation.source.id)) return;
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;
728
801
  evaluation.effect = "deny";
729
802
  evaluation.message = UNATTENDED;
730
803
  });
@@ -1671,14 +1744,11 @@ function satisfies(version, range) {
1671
1744
  return order === 0;
1672
1745
  });
1673
1746
  }
1674
- function checkHost(version, tested, allowUntested) {
1675
- if (satisfies(version, tested)) return { version, tested };
1676
- if (!allowUntested) throw new GranolaError(hostUntested(tested, version));
1677
- return {
1678
- version,
1679
- tested,
1680
- warning: `outside the tested line ${tested}; it runs because allowUntestedHost is true`
1681
- };
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` };
1682
1752
  }
1683
1753
 
1684
1754
  // src/integration.ts
@@ -1818,7 +1888,15 @@ function parseOptions(raw, home = homedir()) {
1818
1888
 
1819
1889
  // src/rpc/register.ts
1820
1890
  async function registerRpc(ctx, status) {
1821
- 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
+ });
1822
1900
  }
1823
1901
 
1824
1902
  // src/server.ts
@@ -1878,7 +1956,7 @@ async function registerSkills(ctx, skills = readSkills()) {
1878
1956
  async function setup(ctx, toolsRpc) {
1879
1957
  const info = readPackageInfo();
1880
1958
  const options = parseOptions(ctx.options);
1881
- const host = checkHost(ctx.app.version, info.testedHost, options.allowUntestedHost);
1959
+ const host = checkHost(ctx.app.version, info.hostRange, options.allowUntestedHost);
1882
1960
  const server = await registerServer(ctx);
1883
1961
  const integration = await registerIntegration(ctx, server.conflict);
1884
1962
  await registerPermissions(ctx);
@@ -1888,7 +1966,7 @@ async function setup(ctx, toolsRpc) {
1888
1966
  workspace: tools?.workspace(),
1889
1967
  conflict: server.conflict(),
1890
1968
  exportDirectory: options.exportDirectory,
1891
- pluginVersion: info.version,
1969
+ version: info.version,
1892
1970
  host
1893
1971
  }));
1894
1972
  tools = await registerTools(ctx);
package/dist/tui.js CHANGED
@@ -4,18 +4,26 @@ import { createSignal } from "solid-js";
4
4
 
5
5
  // src/rpc/contract.ts
6
6
  import { Rpc } from "@opencode/plugin/rpc";
7
+ var STATES = ["connected", "sign_in_required", "connecting", "disabled", "failed", "not_configured", "conflict"];
7
8
  var STATUS_SCHEMA = {
8
9
  type: "object",
9
10
  additionalProperties: false,
10
- required: ["state", "tools", "unguardedAgents", "exportDirectory", "pluginVersion", "hostVersion", "next"],
11
+ required: ["version", "host", "state", "tools", "unguardedAgents", "exportDirectory", "nextStep"],
11
12
  properties: {
12
- state: { type: "string", enum: ["connected", "pending", "disabled", "failed", "needs_auth", "absent"] },
13
+ version: { type: "string" },
14
+ host: {
15
+ type: "object",
16
+ additionalProperties: false,
17
+ required: ["version", "range", "tested"],
18
+ properties: { version: { type: ["string", "null"] }, range: { type: "string" }, tested: { type: "boolean" } }
19
+ },
20
+ state: { type: "string", enum: STATES },
13
21
  title: { type: "string" },
14
22
  problem: {
15
23
  type: "object",
16
24
  additionalProperties: false,
17
- required: ["code", "message", "next"],
18
- properties: { code: { type: "string" }, message: { type: "string" }, next: { type: "string" } }
25
+ required: ["code", "message"],
26
+ properties: { code: { type: "string" }, message: { type: "string" } }
19
27
  },
20
28
  conflict: { type: "string" },
21
29
  tools: {
@@ -45,31 +53,43 @@ var STATUS_SCHEMA = {
45
53
  }
46
54
  },
47
55
  exportDirectory: { type: "string" },
48
- pluginVersion: { type: "string" },
49
- hostVersion: { type: "string" },
50
56
  hostWarning: { type: "string" },
51
- next: { type: "string" }
57
+ nextStep: { type: "string" }
52
58
  }
53
59
  };
60
+ var STATUS_ERROR_SCHEMA = {
61
+ type: "object",
62
+ additionalProperties: false,
63
+ required: ["code", "next"],
64
+ properties: { code: { type: "string" }, next: { type: "string" } }
65
+ };
54
66
  var STATE_LABELS = {
55
67
  connected: "connected",
56
- pending: "connecting",
57
- needs_auth: "sign-in required",
68
+ connecting: "connecting",
69
+ sign_in_required: "sign-in required",
58
70
  failed: "failed",
59
71
  disabled: "disabled",
60
- absent: "not configured"
72
+ not_configured: "not configured",
73
+ conflict: "conflict"
61
74
  };
62
75
  var TONES = {
63
76
  connected: "ok",
64
- pending: "info",
65
- needs_auth: "warning",
77
+ connecting: "info",
78
+ sign_in_required: "warning",
66
79
  failed: "error",
67
80
  disabled: "info",
68
- absent: "warning"
81
+ not_configured: "warning",
82
+ conflict: "warning"
69
83
  };
84
+ function hostRow(status) {
85
+ const { version, range, tested } = status.host;
86
+ const found = version ?? "unknown";
87
+ if (status.hostWarning) return { label: "OpenCode", text: `${found} (host untested: ${status.hostWarning}), plugin ${status.version}`, tone: "warning" };
88
+ return { label: "OpenCode", text: `${found} (${tested ? "tested" : "untested"}; supported range ${range}), plugin ${status.version}`, ...tested ? {} : { tone: "info" } };
89
+ }
70
90
  function statusRows(status) {
71
91
  const connected = status.state === "connected";
72
- const connection = status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
92
+ const connection = status.state === "conflict" && status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
73
93
  const tools = [`${status.tools.visible} available`];
74
94
  if (status.tools.hidden) tools.push(`${status.tools.hidden} hidden`);
75
95
  if (status.tools.unknown) {
@@ -78,18 +98,18 @@ function statusRows(status) {
78
98
  }
79
99
  const agents = status.unguardedAgents;
80
100
  return [
81
- { label: "Connection", text: connection, tone: status.conflict ? "warning" : TONES[status.state] },
101
+ { label: "Connection", text: connection, tone: TONES[status.state] },
82
102
  { label: "Account", text: !connected ? "unknown until Granola connects" : status.workspace ? `workspace ${status.workspace}` : "workspace unknown until the agent reads your Granola account" },
83
103
  { label: "Access", text: "reads run without asking; changes, new tools and exports ask first" },
84
104
  { label: "Export folder", text: status.exportDirectory },
85
105
  { label: "Tools", text: connected ? tools.join(", ") : "none until Granola connects" },
86
- ...agents.length ? [{
106
+ agents.length ? {
87
107
  label: "Agents without default rules",
88
- text: `${agents.join(", ")}: Granola writes run there without asking`,
108
+ text: `${agents.join(", ")}: Granola changes and new tools may run there without asking`,
89
109
  tone: "warning",
90
110
  block: permissionsSnippet(agents, status.permissionsBlock ?? [])
91
- }] : [],
92
- 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}` }
111
+ } : { label: "Agents without default rules", text: "none" },
112
+ hostRow(status)
93
113
  ];
94
114
  }
95
115
  function permissionsSnippet(agents, block) {
@@ -111,7 +131,8 @@ var Granola = Rpc.define({
111
131
  methods: {
112
132
  status: {
113
133
  input: { type: "object", properties: {}, additionalProperties: false },
114
- output: STATUS_SCHEMA
134
+ output: STATUS_SCHEMA,
135
+ errors: { unavailable: STATUS_ERROR_SCHEMA }
115
136
  }
116
137
  },
117
138
  events: {}
@@ -127,10 +148,10 @@ function view(details) {
127
148
  if (details.type === "unavailable") {
128
149
  return {
129
150
  rows: [{ label: "Connection", text: `unknown: ${details.reason}`, tone: "warning" }],
130
- next: "Check that granola.meetings is active in /plugins, then press r to refresh."
151
+ next: details.next ?? "Check that granola.meetings is active in /plugins, then press r to refresh."
131
152
  };
132
153
  }
133
- return { rows: statusRows(details.status), next: details.status.next };
154
+ return { rows: statusRows(details.status), next: details.status.nextStep };
134
155
  }
135
156
 
136
157
  // src/tui/view.tsx
@@ -275,9 +296,17 @@ function StatusDialog(props) {
275
296
  }
276
297
 
277
298
  // src/tui/index.tsx
278
- function reason(error) {
299
+ function failure(error) {
279
300
  const type = typeof error === "object" && error !== null && "type" in error ? error.type : void 0;
280
- return type === "rpc.unavailable" || type === "rpc.method_not_found" ? "the Granola plugin is not running on this OpenCode server" : "the status request failed";
301
+ if (type === "unavailable") return {
302
+ type: "unavailable",
303
+ reason: "Granola status could not be read (STATUS_UNAVAILABLE)",
304
+ next: "Press r to refresh in a few seconds."
305
+ };
306
+ return {
307
+ type: "unavailable",
308
+ reason: type === "rpc.unavailable" || type === "rpc.method_not_found" ? "the Granola plugin is not running on this OpenCode server" : "the status request failed"
309
+ };
281
310
  }
282
311
  var plugin = {
283
312
  id: "granola.meetings.tui",
@@ -297,10 +326,7 @@ var plugin = {
297
326
  } : {}).then((status) => ({
298
327
  type: "ready",
299
328
  status
300
- }), (error) => ({
301
- type: "unavailable",
302
- reason: reason(error)
303
- })).then((next) => {
329
+ }), failure).then((next) => {
304
330
  if (request === generation) setDetails(next);
305
331
  });
306
332
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joe-broadhead/opencode-granola",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Unofficial OpenCode plugin for Granola meeting notes, through Granola's official MCP server",
5
5
  "keywords": [
6
6
  "opencode",
@@ -22,7 +22,7 @@ For a brief before a meeting, or a catch-up on a person, company or topic, also
22
22
  - `search_meetings`: by keyword, participant or company, and date. Keywords match titles, summaries and the user's notes, not transcripts, so an empty result does not mean that a topic was never discussed.
23
23
  - `list_meetings`: meetings in a date range, newest first (the last 7 days by default), optionally in one folder.
24
24
  - `search_transcripts`: by keyword in what was said; it returns matching transcript passages. Not every Granola plan includes transcripts.
25
- - `list_meeting_folders`: folders and their IDs, for the folder filter of both tools above.
25
+ - `list_meeting_folders`: folders and their IDs, for the `folder_id` filter of `search_meetings` and `list_meetings`.
26
26
  - Results come in pages. While a response offers a next cursor, continue with it and the same filters before you say that a meeting does not exist.
27
27
 
28
28
  ## Read meetings