@paneui/mcp 0.0.19 → 0.0.20

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/tools.js CHANGED
@@ -1,25 +1,48 @@
1
1
  // Tool definitions for the Pane MCP server.
2
2
  //
3
- // Each tool wraps a @paneui/core PaneClient operation. The descriptions are
4
- // written for the LLM consumer — they ARE the docs the model reads to decide
5
- // when and how to call each tool. Keep them concrete and action-oriented.
3
+ // Each tool wraps one or more @paneui/core PaneClient operations. The
4
+ // descriptions are written for the LLM consumer — they ARE the docs the model
5
+ // reads to decide when and how to call each tool. Keep them concrete and
6
+ // action-oriented.
6
7
  //
7
- // Design notes:
8
- // - MCP tools are request/response. There is no long-lived "watch" — instead
9
- // `get_events` is a poll: the model calls it with the cursor from the last
10
- // call until the awaited event appears. Each description spells out the
11
- // poll loop so the model drives it correctly.
12
- // - Schema validation is done with Zod raw shapes (the shape the MCP SDK's
13
- // registerTool expects); the SDK validates arguments against them before
14
- // the handler runs, so handlers receive typed, validated input.
8
+ // Surface design (full parity with the `pane` CLI):
9
+ // - Hot-path nouns are DISCRETE tools with sharp descriptions: create_pane,
10
+ // get_pane_state, get_events, send_to_pane, update_pane, delete_pane,
11
+ // upgrade_pane, list_panes, and the four record CRUD tools (list_records,
12
+ // upsert_record, update_record, delete_record).
13
+ // - Multi-verb MANAGEMENT nouns each collapse into ONE tool with a required
14
+ // `action` enum and per-action fields: records_admin (template/per-pane
15
+ // collection admin lives under the discrete record tools + this one for the
16
+ // less-common get/delete-collection/poll), template, template_records,
17
+ // participant, share, attachments, taste, key, trash, feedback, agent.
18
+ // - query → run_query (read-only SQL). skill → get_skill (no API key).
19
+ //
20
+ // MCP is request/response: there is no streaming. The CLI's `watch` becomes a
21
+ // long-poll — get_events (events) or list_records with a cursor (records). Each
22
+ // description spells out the poll loop so the model drives it correctly.
23
+ //
24
+ // Schema validation uses Zod raw shapes (the shape McpServer.registerTool
25
+ // expects); the SDK validates arguments before the handler runs. For
26
+ // consolidated tools the per-action required fields are documented in the tool
27
+ // description and re-checked in the handler (a Zod raw shape can't express a
28
+ // discriminated union across a flat field set, so the handler asserts the
29
+ // action-specific requirements and returns a tight invalid_args error).
15
30
  import { z } from "zod";
16
31
  import { PaneApiError } from "@paneui/core";
32
+ import { readFileSync, writeFileSync } from "node:fs";
33
+ import { basename } from "node:path";
34
+ import { resolveUrl, describeActiveConfig, clearActiveProfile, } from "./config.js";
35
+ import { fetchSkill } from "./skill.js";
17
36
  /** Wrap a JSON-able value as a single text-content tool result. */
18
37
  function jsonResult(value) {
19
38
  return {
20
39
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
21
40
  };
22
41
  }
42
+ /** Plain text result (used by get_skill for raw markdown). */
43
+ function textResult(text) {
44
+ return { content: [{ type: "text", text }] };
45
+ }
23
46
  /**
24
47
  * Turn any thrown error into a structured `isError` tool result. PaneApiError
25
48
  * carries the relay's `code`, HTTP `status`, and an optional remediation
@@ -55,30 +78,67 @@ function errorResult(e) {
55
78
  isError: true,
56
79
  };
57
80
  }
58
- // ---------------------------------------------------------------------------
59
- // Tool input schemas
60
- // ---------------------------------------------------------------------------
81
+ /**
82
+ * Structured invalid_args error for the per-action validation inside
83
+ * consolidated tools. Mirrors the relay's envelope so the model self-corrects.
84
+ */
85
+ function invalidArgs(message) {
86
+ return {
87
+ content: [
88
+ {
89
+ type: "text",
90
+ text: JSON.stringify({ error: "invalid_args", message }, null, 2),
91
+ },
92
+ ],
93
+ isError: true,
94
+ };
95
+ }
96
+ /** Read a required string arg; returns undefined when absent/empty. */
97
+ function str(args, key) {
98
+ const v = args[key];
99
+ return typeof v === "string" && v !== "" ? v : undefined;
100
+ }
101
+ // ===========================================================================
102
+ // Hot-path discrete tools
103
+ // ===========================================================================
61
104
  const createPaneShape = {
62
105
  name: z
63
106
  .string()
64
107
  .min(1)
65
- .describe("Short human-readable label for the auto-created template, shown in the owner UI (e.g. 'Deploy approval', 'Vendor picker')."),
108
+ .optional()
109
+ .describe("Short human-readable label for the auto-created template (e.g. 'Deploy approval'). REQUIRED when you pass `html` (inline form); omit when reusing an existing template via `template_id` (it inherits the template's name)."),
66
110
  html: z
67
111
  .string()
68
112
  .min(1)
69
- .describe("The pane's UI as a complete inline HTML document. To send data back to you, the page calls window.pane.emit(eventType, payload) — every emitted eventType MUST be declared in event_schema below with 'page' in its emittedBy. Read window.pane.inputData for seed data passed via input_data."),
113
+ .optional()
114
+ .describe("The pane's UI as a complete inline HTML document. To send data back to you, the page calls window.pane.emit(eventType, payload) — every emitted eventType MUST be declared in event_schema with 'page' in its emittedBy. Read window.pane.inputData for seed data. Pass EITHER `html` (+`name`) for a one-off, OR `template_id` to reuse a saved template — not both."),
115
+ template_id: z
116
+ .string()
117
+ .min(1)
118
+ .optional()
119
+ .describe("Reuse an existing named template (id or slug) instead of inline HTML. The template's pinned version supplies the HTML + event/input/record schemas. Mutually exclusive with `html`/`name`/`event_schema`/`input_schema`. Create templates with the `template` tool."),
120
+ template_version: z
121
+ .number()
122
+ .int()
123
+ .positive()
124
+ .optional()
125
+ .describe("With `template_id`: pin this pane to a specific template version. Defaults to the template head's latest version."),
70
126
  event_schema: z
71
127
  .record(z.string(), z.unknown())
72
128
  .optional()
73
- .describe("Optional. Declares which events the page (and you) may emit and validates each payload. Shape: { events: { '<type>': { emittedBy: ['page'|'agent'...], payload: <JSON Schema> } } }. OMIT for a read-only pane (dashboard/status view the human only looks at — it then accepts no events)."),
74
- input_data: z
129
+ .describe("Inline form only. Declares which events the page (and you) may emit and validates each payload. Shape: { events: { '<type>': { emittedBy: ['page'|'agent'...], payload: <JSON Schema> } } }. OMIT for a read-only pane."),
130
+ input_schema: z
75
131
  .record(z.string(), z.unknown())
76
132
  .optional()
77
- .describe("Optional seed data for this pane instance, readable in the page as window.pane.inputData (e.g. the diff to review, the options to pick from)."),
78
- input_schema: z
133
+ .describe("Inline form only. Optional JSON Schema validating input_data. Needed if input_data references uploaded attachment ids the page must download."),
134
+ record_schema: z
135
+ .record(z.string(), z.unknown())
136
+ .optional()
137
+ .describe("Inline form only. JSON Schema 2020-12 doc with an `x-pane-collections` extension declaring this pane's mutable record collections (todos, comments…). OMIT for an event-only pane."),
138
+ input_data: z
79
139
  .record(z.string(), z.unknown())
80
140
  .optional()
81
- .describe("Optional JSON Schema validating input_data. Only needed if input_data references uploaded attachment ids the page must download."),
141
+ .describe("Optional seed data for this pane instance, readable in the page as window.pane.inputData (e.g. the diff to review, the options to pick from)."),
82
142
  title: z
83
143
  .string()
84
144
  .optional()
@@ -87,19 +147,45 @@ const createPaneShape = {
87
147
  .string()
88
148
  .max(300)
89
149
  .optional()
90
- .describe("Optional one/two-line context shown above the UI — 'who is asking, and why'. Use it whenever the pane isn't self-explanatory."),
150
+ .describe("Optional one/two-line context shown above the UI — 'who is asking, and why'."),
91
151
  ttl_seconds: z
92
152
  .number()
93
153
  .int()
94
154
  .positive()
95
155
  .optional()
96
156
  .describe("Optional pane lifetime in seconds. The relay clamps to its max; the returned expires_at is authoritative."),
157
+ participants: z
158
+ .number()
159
+ .int()
160
+ .positive()
161
+ .optional()
162
+ .describe("Optional number of distinct human participant URLs to mint (default 1). Each gets its own URL in the returned `urls` array."),
163
+ metadata: z
164
+ .record(z.string(), z.unknown())
165
+ .optional()
166
+ .describe("Optional opaque JSON you can attach to the pane for your own bookkeeping (never shown to the human, queryable via run_query)."),
167
+ tags: z
168
+ .array(z.string())
169
+ .optional()
170
+ .describe("Optional per-pane filter tags (merged with the template's tags). ≤20 tags, ≤50 chars each; 'favorite'/'favorites' are reserved."),
171
+ icon_emoji: z
172
+ .string()
173
+ .optional()
174
+ .describe("Optional single-emoji icon override for this pane."),
175
+ icon_attachment_id: z
176
+ .string()
177
+ .optional()
178
+ .describe("Optional per-pane icon as a ready raster-image attachment id (png/jpeg/webp/gif). Upload it first via the `attachments` tool (scope: pane or agent)."),
179
+ callback: z
180
+ .record(z.string(), z.unknown())
181
+ .optional()
182
+ .describe("Optional webhook callback config so the relay POSTs new events to your endpoint. Shape per the relay's callback schema (e.g. { url, secret? }). Most MCP agents poll with get_events instead."),
97
183
  context_key: z
98
184
  .string()
99
185
  .min(1)
100
186
  .max(256)
101
187
  .optional()
102
- .describe("Optional natural key (e.g. 'pr-42', 'deal-1138'). Repeated create_pane calls with the same (template, key) return the SAME pane instead of a new one use it to make retries idempotent."),
188
+ .describe("Optional natural key (e.g. 'pr-42'). Repeated create_pane calls with the same (template, key) return the SAME pane — makes retries idempotent."),
103
189
  };
104
190
  const getPaneStateShape = {
105
191
  pane_id: z.string().min(1).describe("The pane id returned by create_pane."),
@@ -116,14 +202,14 @@ const getEventsShape = {
116
202
  .min(0)
117
203
  .max(30)
118
204
  .optional()
119
- .describe("Optional long-poll: how long the relay holds the request open waiting for a new event (0–30s, relay-capped). Use ~25 when waiting for a human to act, so each poll either returns promptly with the event or returns empty and you call again with the same cursor."),
205
+ .describe("Optional long-poll: how long the relay holds the request open waiting for a new event (0–30s). Use ~25 when waiting for a human to act, then call again with the same cursor."),
120
206
  };
121
207
  const sendToPaneShape = {
122
208
  pane_id: z.string().min(1).describe("The pane id to push the event into."),
123
209
  type: z
124
210
  .string()
125
211
  .min(1)
126
- .describe("Event type. Must be declared in the pane's event_schema with 'agent' in its emittedBy list (the page sees it live)."),
212
+ .describe("Event type. Must be declared in the pane's event_schema with 'agent' in its emittedBy list."),
127
213
  data: z
128
214
  .unknown()
129
215
  .describe("Event payload — any JSON value valid against the type's payload schema. Use {} or null for a no-payload event."),
@@ -132,6 +218,87 @@ const sendToPaneShape = {
132
218
  .optional()
133
219
  .describe("Optional dedup key — a repeat send with the same key is a no-op."),
134
220
  };
221
+ const updatePaneShape = {
222
+ pane_id: z.string().min(1).describe("The pane id to edit."),
223
+ ttl_seconds: z
224
+ .number()
225
+ .int()
226
+ .positive()
227
+ .optional()
228
+ .describe("Reset the pane's lifetime to now + this many seconds. Mutually exclusive with expires_at."),
229
+ expires_at: z
230
+ .string()
231
+ .optional()
232
+ .describe("Set expires_at to a specific future ISO-8601 timestamp. Mutually exclusive with ttl_seconds."),
233
+ title: z.string().optional().describe("New tab title."),
234
+ preamble: z
235
+ .string()
236
+ .optional()
237
+ .describe("New preamble (context band above the UI)."),
238
+ input_data: z
239
+ .record(z.string(), z.unknown())
240
+ .optional()
241
+ .describe("Replace the pane's input_data wholesale (revalidated against the pinned template version's input_schema)."),
242
+ metadata: z
243
+ .record(z.string(), z.unknown())
244
+ .optional()
245
+ .describe("Replace the pane's metadata wholesale."),
246
+ tags: z.array(z.string()).optional().describe("Replace the per-pane tags."),
247
+ icon_emoji: z.string().optional().describe("Set the per-pane emoji icon."),
248
+ icon_attachment_id: z
249
+ .string()
250
+ .optional()
251
+ .describe("Set the per-pane icon to a ready raster-image attachment id."),
252
+ clear_icon_emoji: z
253
+ .boolean()
254
+ .optional()
255
+ .describe("Clear the emoji override (fall back to the template's icon)."),
256
+ clear_icon_attachment_id: z
257
+ .boolean()
258
+ .optional()
259
+ .describe("Clear the attachment icon override (fall back to the template's icon)."),
260
+ };
261
+ const upgradePaneShape = {
262
+ pane_id: z.string().min(1).describe("The pane id to re-pin."),
263
+ template_version: z
264
+ .number()
265
+ .int()
266
+ .positive()
267
+ .optional()
268
+ .describe("Target version of the SAME template. Defaults to the template head's latest version."),
269
+ force: z
270
+ .boolean()
271
+ .optional()
272
+ .describe("Override the strict schema-compat gate (compat=force). Without it, an upgrade that would narrow the schema is refused with schema_incompatible_upgrade + details.breaks."),
273
+ };
274
+ const listPanesShape = {
275
+ status: z
276
+ .enum(["open", "closed", "all"])
277
+ .optional()
278
+ .describe("Filter by effective status. Default: open."),
279
+ limit: z
280
+ .number()
281
+ .int()
282
+ .positive()
283
+ .max(200)
284
+ .optional()
285
+ .describe("Page size (default 50, max 200)."),
286
+ cursor: z
287
+ .string()
288
+ .optional()
289
+ .describe("Opaque cursor from a previous page's next_cursor."),
290
+ template_id: z
291
+ .string()
292
+ .optional()
293
+ .describe("Filter to panes instantiated from a specific named template (head id, not version id)."),
294
+ };
295
+ const deletePaneShape = {
296
+ pane_id: z
297
+ .string()
298
+ .min(1)
299
+ .describe("The pane id to close/delete (idempotent)."),
300
+ };
301
+ // ----- record CRUD (hot-path, kept discrete + back-compatible) -------------
135
302
  const listRecordsShape = {
136
303
  pane_id: z.string().min(1).describe("The pane id."),
137
304
  collection: z
@@ -142,8 +309,23 @@ const listRecordsShape = {
142
309
  .number()
143
310
  .int()
144
311
  .optional()
145
- .describe("Optional cursor (next_since from a prior call) for pagination."),
146
- limit: z.number().int().positive().optional().describe("Optional page size."),
312
+ .describe("Optional cursor (next_since from a prior call). Also the POLL handle: to watch a collection (no streaming in MCP), call repeatedly passing the previous next_since to fetch only newer/changed rows."),
313
+ limit: z
314
+ .number()
315
+ .int()
316
+ .positive()
317
+ .max(200)
318
+ .optional()
319
+ .describe("Optional page size (max 200)."),
320
+ include_tombstones: z
321
+ .boolean()
322
+ .optional()
323
+ .describe("Include soft-deleted rows (deleted_at set) so you can observe deletions. Default false."),
324
+ };
325
+ const getRecordShape = {
326
+ pane_id: z.string().min(1).describe("The pane id."),
327
+ collection: z.string().min(1).describe("The record collection name."),
328
+ record_key: z.string().min(1).describe("The key of the record to fetch."),
147
329
  };
148
330
  const upsertRecordShape = {
149
331
  pane_id: z.string().min(1).describe("The pane id."),
@@ -151,7 +333,7 @@ const upsertRecordShape = {
151
333
  record_key: z
152
334
  .string()
153
335
  .optional()
154
- .describe("Optional stable key for this record. Reusing an existing key returns the existing row (deduped:true) rather than creating a duplicate; omit to let the relay assign one."),
336
+ .describe("Optional stable key. Reusing an existing key returns the existing row (deduped:true)."),
155
337
  data: z
156
338
  .unknown()
157
339
  .describe("The record body — any JSON value valid against the collection schema."),
@@ -165,32 +347,363 @@ const updateRecordShape = {
165
347
  .number()
166
348
  .int()
167
349
  .optional()
168
- .describe("Optional optimistic-lock version. If it doesn't match the current row, the update is rejected with the current row in details.current."),
350
+ .describe("Optional optimistic-lock version. On mismatch the update is rejected with the current row in details.current."),
169
351
  };
170
352
  const deleteRecordShape = {
171
353
  pane_id: z.string().min(1).describe("The pane id."),
172
354
  collection: z.string().min(1).describe("The record collection name."),
173
355
  record_key: z.string().min(1).describe("The key of the record to delete."),
356
+ if_match: z
357
+ .number()
358
+ .int()
359
+ .optional()
360
+ .describe("Optional optimistic-lock version."),
361
+ };
362
+ // ===========================================================================
363
+ // Consolidated management tools
364
+ // ===========================================================================
365
+ const templateShape = {
366
+ action: z
367
+ .enum([
368
+ "create",
369
+ "version",
370
+ "update",
371
+ "search",
372
+ "list",
373
+ "show",
374
+ "get_version",
375
+ "delete",
376
+ "publish",
377
+ "unpublish",
378
+ "search_public",
379
+ "set_icon",
380
+ ])
381
+ .describe("Which template operation to run. create: a new named template (needs name+html). version: append a new immutable version to an existing template (id+html). update: patch head metadata (name/slug/description/tags). search/list: find the agent's templates (search takes an optional query). show: full template + version list (id). get_version: one version's content (id+version). delete: remove the template + all versions (id, requires confirm:true). publish/unpublish: public catalog (id). search_public: the public catalog across all agents (optional query). set_icon: set/clear a template's icon (id + one of emoji / icon_attachment_id / clear)."),
382
+ id: z
383
+ .string()
384
+ .optional()
385
+ .describe("Template id or slug. Required for version/update/show/get_version/delete/publish/unpublish/set_icon."),
386
+ query: z
387
+ .string()
388
+ .optional()
389
+ .describe("Free-text search (for search / search_public)."),
390
+ name: z
391
+ .string()
392
+ .optional()
393
+ .describe("Template display name (required for create)."),
394
+ slug: z
395
+ .string()
396
+ .optional()
397
+ .describe("Stable agent-chosen handle (create/update)."),
398
+ description: z
399
+ .string()
400
+ .optional()
401
+ .describe("Prose description (create/update)."),
402
+ tags: z
403
+ .array(z.string())
404
+ .optional()
405
+ .describe("Search keywords (create/update)."),
406
+ html: z
407
+ .string()
408
+ .optional()
409
+ .describe("HTML template body / source (required for create + version)."),
410
+ template_type: z
411
+ .enum(["html-inline", "html-ref"])
412
+ .optional()
413
+ .describe("Source kind. Default html-inline; html-ref treats html as a URL."),
414
+ event_schema: z
415
+ .record(z.string(), z.unknown())
416
+ .optional()
417
+ .describe("Event schema (create/version). Omit for a view-only template."),
418
+ input_schema: z
419
+ .record(z.string(), z.unknown())
420
+ .optional()
421
+ .describe("Per-pane input_data JSON Schema (create/version)."),
422
+ record_schema: z
423
+ .record(z.string(), z.unknown())
424
+ .optional()
425
+ .describe("Per-pane record collections schema (create/version)."),
426
+ template_record_schema: z
427
+ .record(z.string(), z.unknown())
428
+ .optional()
429
+ .describe("Template-level (shared) record collections schema (create/version). Set this before using the template_records tool."),
430
+ version: z
431
+ .number()
432
+ .int()
433
+ .positive()
434
+ .optional()
435
+ .describe("Version number (required for get_version)."),
436
+ scopes: z
437
+ .array(z.string())
438
+ .optional()
439
+ .describe("verb:noun permission scopes for publish (e.g. ['read:agent']). Empty array clears them."),
440
+ limit: z
441
+ .number()
442
+ .int()
443
+ .positive()
444
+ .max(50)
445
+ .optional()
446
+ .describe("search_public page size (1..50)."),
447
+ offset: z.number().int().min(0).optional().describe("search_public offset."),
448
+ icon_emoji: z.string().optional().describe("set_icon: a single-emoji icon."),
449
+ icon_attachment_id: z
450
+ .string()
451
+ .optional()
452
+ .describe("set_icon: a ready template-scoped raster-image attachment id."),
453
+ clear: z
454
+ .boolean()
455
+ .optional()
456
+ .describe("set_icon: clear both the emoji and image icon."),
457
+ confirm: z
458
+ .boolean()
459
+ .optional()
460
+ .describe("Required (true) for the destructive `delete` action."),
461
+ };
462
+ const templateRecordsShape = {
463
+ action: z
464
+ .enum(["list", "get", "upsert", "update", "delete", "delete_collection"])
465
+ .describe("Operation on a TEMPLATE-level (owner-curated, shared across every pane of the template) record collection. Same grammar as the per-pane record tools but scoped to a template head. The template version must declare the collection via template_record_schema (set it with the `template` tool)."),
466
+ template_id: z.string().min(1).describe("Template id or slug."),
467
+ collection: z.string().min(1).describe("The template-level collection name."),
468
+ record_key: z
469
+ .string()
470
+ .optional()
471
+ .describe("Record key. Required for get/update/delete; optional for upsert."),
472
+ data: z
473
+ .unknown()
474
+ .optional()
475
+ .describe("Record body. Required for upsert/update."),
476
+ if_match: z
477
+ .number()
478
+ .int()
479
+ .optional()
480
+ .describe("Optimistic-lock version for update/delete."),
481
+ since: z.number().int().optional().describe("List cursor (and poll handle)."),
482
+ limit: z
483
+ .number()
484
+ .int()
485
+ .positive()
486
+ .max(200)
487
+ .optional()
488
+ .describe("List page size."),
489
+ include_tombstones: z
490
+ .boolean()
491
+ .optional()
492
+ .describe("Include soft-deleted rows in list."),
493
+ confirm: z
494
+ .boolean()
495
+ .optional()
496
+ .describe("Required (true) for delete_collection (drops the whole collection)."),
497
+ };
498
+ const participantShape = {
499
+ action: z
500
+ .enum(["list", "new", "revoke"])
501
+ .describe("Manage a pane's participant URLs. list: every participant (active + revoked) — use it to find a participant_id. new: mint a FRESH human URL on an existing pane (the plaintext token is returned ONCE — save it before delivering). revoke: invalidate one participant URL."),
502
+ pane_id: z.string().min(1).describe("The pane id."),
503
+ participant_id: z
504
+ .string()
505
+ .optional()
506
+ .describe("The participant id to revoke (required for revoke)."),
507
+ };
508
+ const shareShape = {
509
+ action: z
510
+ .enum(["list", "invite", "set_access", "revoke"])
511
+ .describe("Identity sharing on a pane. list: access_mode + all grants. invite: invite a human by email (role participant|viewer). set_access: set the /p access mode (invite_only|link|public). revoke: remove one grant by id. Token (/s/<token>) links are independent of access_mode."),
512
+ pane_id: z.string().min(1).describe("The pane id."),
513
+ email: z.string().optional().describe("Invitee email (required for invite)."),
514
+ role: z
515
+ .enum(["participant", "viewer"])
516
+ .optional()
517
+ .describe("Grant role for invite (default participant)."),
518
+ access_mode: z
519
+ .enum(["invite_only", "link", "public"])
520
+ .optional()
521
+ .describe("Access mode for set_access."),
522
+ grant_id: z
523
+ .string()
524
+ .optional()
525
+ .describe("Grant id to revoke (required for revoke)."),
526
+ };
527
+ const attachmentsShape = {
528
+ action: z
529
+ .enum([
530
+ "upload",
531
+ "download",
532
+ "show",
533
+ "list",
534
+ "delete",
535
+ "mint_token",
536
+ "revoke_token",
537
+ "list_tokens",
538
+ ])
539
+ .describe("Binary attachment operations. upload: read a local file (file_path) and upload it; scope agent|pane|template. download: fetch bytes by attachment_id to out_path (absolute) or return base64. show: metadata only. list: the agent's attachments. delete: soft-delete. mint_token: mint a /b/<token> capability URL (returned ONCE). revoke_token / list_tokens: manage those tokens."),
540
+ attachment_id: z
541
+ .string()
542
+ .optional()
543
+ .describe("Attachment id. Required for download/show/delete/mint_token/revoke_token/list_tokens."),
544
+ file_path: z
545
+ .string()
546
+ .optional()
547
+ .describe("upload: ABSOLUTE path to the local file to upload."),
548
+ scope: z
549
+ .enum(["agent", "pane", "template"])
550
+ .optional()
551
+ .describe("upload scope (default agent)."),
552
+ pane_id: z.string().optional().describe("Required when scope=pane."),
553
+ template_id: z.string().optional().describe("Required when scope=template."),
554
+ filename: z
555
+ .string()
556
+ .optional()
557
+ .describe("upload: display filename (defaults to the file's basename)."),
558
+ mime: z
559
+ .string()
560
+ .optional()
561
+ .describe("upload: advisory Content-Type (the relay sniffs the bytes regardless)."),
562
+ out_path: z
563
+ .string()
564
+ .optional()
565
+ .describe("download: ABSOLUTE path to write the bytes to. If omitted, the bytes are returned base64-encoded in the result."),
566
+ cursor: z.string().optional().describe("list pagination cursor."),
567
+ limit: z
568
+ .number()
569
+ .int()
570
+ .positive()
571
+ .max(100)
572
+ .optional()
573
+ .describe("list page size (1..100)."),
574
+ ttl_seconds: z
575
+ .number()
576
+ .int()
577
+ .positive()
578
+ .optional()
579
+ .describe("mint_token: per-token TTL (clamped by scope default)."),
580
+ once: z
581
+ .boolean()
582
+ .optional()
583
+ .describe("mint_token: token self-deletes on first GET."),
584
+ token_id: z
585
+ .string()
586
+ .optional()
587
+ .describe("revoke_token: the token id to revoke."),
588
+ };
589
+ const tasteShape = {
590
+ action: z
591
+ .enum(["get", "set", "clear"])
592
+ .describe("The agent's freeform UI taste notes (markdown) — presentation preferences learned from human feedback. get: read them before generating a pane. set: whole-document replace (taste, non-empty). clear: delete them."),
593
+ taste: z
594
+ .string()
595
+ .optional()
596
+ .describe("The full markdown notes (required for set; whole-document replace, not append)."),
597
+ };
598
+ const keyShape = {
599
+ action: z
600
+ .enum(["list", "revoke"])
601
+ .describe("The calling agent's API key. list: key info (agent_id, key_prefix, timestamps). revoke: self-destruct the agent's OWN key — it stops working immediately and is irreversible (requires confirm:true)."),
602
+ confirm: z.boolean().optional().describe("Required (true) for revoke."),
603
+ };
604
+ const trashShape = {
605
+ action: z
606
+ .enum(["list", "restore", "restore_template", "purge", "purge_template"])
607
+ .describe("Soft-delete trash. list: trashed panes + templates. restore/purge: un-trash or hard-delete a pane (id). restore_template/purge_template: same for a template (id|slug). purge bypasses the retention window (permanent)."),
608
+ id: z
609
+ .string()
610
+ .optional()
611
+ .describe("Pane id (restore/purge) or template id|slug (restore_template/purge_template)."),
612
+ };
613
+ const feedbackShape = {
614
+ action: z
615
+ .enum(["create", "list"])
616
+ .describe("Feedback to the relay operator. create: submit a bug|feature|note with a message (optional pane_id). list: the agent's own submissions, newest first."),
617
+ type: z
618
+ .enum(["bug", "feature", "note"])
619
+ .optional()
620
+ .describe("Feedback category (required for create)."),
621
+ message: z
622
+ .string()
623
+ .optional()
624
+ .describe("Message body (required for create)."),
625
+ pane_id: z
626
+ .string()
627
+ .optional()
628
+ .describe("Optional pane this feedback relates to (create)."),
629
+ limit: z
630
+ .number()
631
+ .int()
632
+ .positive()
633
+ .max(100)
634
+ .optional()
635
+ .describe("list page size (default 50, max 100)."),
636
+ before: z
637
+ .string()
638
+ .optional()
639
+ .describe("list cursor from a prior page's next_before."),
640
+ };
641
+ const agentShape = {
642
+ action: z
643
+ .enum(["whoami", "claim", "logout"])
644
+ .describe("Agent identity. whoami: show the resolved relay URL, active profile, and whether a key is configured (no network, no secrets). claim: bind this agent to a human via a one-shot claim code the human generated in their Settings UI (one-way). logout: clear the locally-saved key/profile (does NOT revoke it on the relay — use the key tool's revoke for that)."),
645
+ code: z
646
+ .string()
647
+ .optional()
648
+ .describe("The one-shot claim code (required for claim)."),
649
+ };
650
+ const runQueryShape = {
651
+ sql: z
652
+ .string()
653
+ .min(1)
654
+ .describe("Read-only SQL (SELECT/WITH/SHOW/DESCRIBE/EXPLAIN/PRAGMA) over your scoped data. Tables: panes(id,title,template_id,template_version,status,created_at,expires_at,deleted_at,metadata,input_data), records(id,pane_id,collection,key,data,version,seq,author_kind,author_id,created_at,updated_at,deleted_at), events(id,pane_id,type,ts,author_kind,author_id,data,template_version_id). `data` is JSON — project with ->> / ->. Capped at 10k rows; 10s timeout."),
655
+ pane_id: z
656
+ .string()
657
+ .optional()
658
+ .describe("Scope the query to a single pane (resolves a view_conflict when two of your panes share a collection name with different schemas)."),
659
+ format: z
660
+ .enum(["json", "csv", "tsv", "table"])
661
+ .optional()
662
+ .describe("Output format. Default json (columns+rows+meta). csv/tsv/table render the rows as text."),
663
+ };
664
+ const getSkillShape = {
665
+ version_only: z
666
+ .boolean()
667
+ .optional()
668
+ .describe("If true, return only the relay's current skill version string instead of the full SKILL.md markdown."),
174
669
  };
175
- // ---------------------------------------------------------------------------
670
+ // ===========================================================================
176
671
  // Tool definitions
177
- // ---------------------------------------------------------------------------
672
+ // ===========================================================================
178
673
  export const TOOLS = [
179
674
  {
180
675
  name: "create_pane",
181
- description: "Hand the human a rich interactive UI by URL and (optionally) get structured data back. Build the UI as inline HTML; the relay hosts it and returns a URL. ALWAYS give the returned url (result.url) to the human — paste it into the conversation and ask them to open it. Reach for this whenever a text reply is the wrong shape: forms, approvals, pickers, surveys, dashboards, diff/doc review, multi-step wizards. If the page captures input it emits events back to you (poll them with get_events). A read-only dashboard with no event_schema is valid too. Returns { pane_id, url, expires_at }.",
676
+ description: "Hand the human a rich interactive UI by URL and (optionally) get structured data back. Build the UI as inline HTML (pass `name` + `html`) OR reuse a saved template (pass `template_id`). The relay hosts it and returns a URL. ALWAYS give the returned url to the human — paste it into the conversation and ask them to open it. Reach for this whenever a text reply is the wrong shape: forms, approvals, pickers, surveys, dashboards, diff/doc review, wizards. If the page captures input it emits events back to you (poll them with get_events) or mutates record collections (the record tools). Returns { pane_id, url, urls, title, expires_at }.",
182
677
  inputSchema: createPaneShape,
183
678
  handler: async (client, args) => {
184
679
  try {
185
- const template = {
186
- name: args["name"],
187
- type: "html-inline",
188
- source: args["html"],
189
- };
190
- if (args["event_schema"] !== undefined)
191
- template["event_schema"] = args["event_schema"];
192
- if (args["input_schema"] !== undefined)
193
- template["input_schema"] = args["input_schema"];
680
+ const hasTemplateId = str(args, "template_id") !== undefined;
681
+ const hasHtml = str(args, "html") !== undefined;
682
+ if (hasTemplateId === hasHtml) {
683
+ return invalidArgs("pass exactly one of `html` (inline form, with `name`) or `template_id` (reuse a saved template)");
684
+ }
685
+ let template;
686
+ if (hasTemplateId) {
687
+ template = { id: args["template_id"] };
688
+ if (args["template_version"] !== undefined)
689
+ template["version"] = args["template_version"];
690
+ }
691
+ else {
692
+ if (str(args, "name") === undefined) {
693
+ return invalidArgs("`name` is required with `html` (inline form)");
694
+ }
695
+ template = {
696
+ name: args["name"],
697
+ type: "html-inline",
698
+ source: args["html"],
699
+ };
700
+ if (args["event_schema"] !== undefined)
701
+ template["event_schema"] = args["event_schema"];
702
+ if (args["input_schema"] !== undefined)
703
+ template["input_schema"] = args["input_schema"];
704
+ if (args["record_schema"] !== undefined)
705
+ template["record_schema"] = args["record_schema"];
706
+ }
194
707
  const req = { template };
195
708
  if (args["input_data"] !== undefined)
196
709
  req["input_data"] = args["input_data"];
@@ -200,14 +713,24 @@ export const TOOLS = [
200
713
  req["preamble"] = args["preamble"];
201
714
  if (args["ttl_seconds"] !== undefined)
202
715
  req["ttl"] = args["ttl_seconds"];
716
+ if (args["participants"] !== undefined)
717
+ req["participants"] = { humans: args["participants"] };
718
+ if (args["metadata"] !== undefined)
719
+ req["metadata"] = args["metadata"];
720
+ if (args["tags"] !== undefined)
721
+ req["tags"] = args["tags"];
722
+ if (args["icon_emoji"] !== undefined)
723
+ req["icon_emoji"] = args["icon_emoji"];
724
+ if (args["icon_attachment_id"] !== undefined)
725
+ req["icon_attachment_id"] = args["icon_attachment_id"];
726
+ if (args["callback"] !== undefined)
727
+ req["callback"] = args["callback"];
203
728
  if (args["context_key"] !== undefined)
204
729
  req["context_key"] = args["context_key"];
205
730
  const res = await client.createPane(req);
206
731
  const humanUrl = res.urls.humans[0] ?? null;
207
732
  return jsonResult({
208
733
  pane_id: res.pane_id,
209
- // The single URL to deliver to the human. (urls.humans carries all
210
- // of them when participants > 1.)
211
734
  url: humanUrl,
212
735
  urls: res.urls.humans,
213
736
  title: res.title,
@@ -225,8 +748,7 @@ export const TOOLS = [
225
748
  inputSchema: getPaneStateShape,
226
749
  handler: async (client, args) => {
227
750
  try {
228
- const state = await client.getPane(String(args["pane_id"]));
229
- return jsonResult(state);
751
+ return jsonResult(await client.getPane(String(args["pane_id"])));
230
752
  }
231
753
  catch (e) {
232
754
  return errorResult(e);
@@ -268,9 +790,115 @@ export const TOOLS = [
268
790
  }
269
791
  },
270
792
  },
793
+ {
794
+ name: "update_pane",
795
+ description: "Edit instance-level fields on a LIVE pane in place (PATCH) without minting a new one — the pane keeps its id, URL, event log, and template pin. Settable: ttl_seconds OR expires_at (mutually exclusive), title, preamble, input_data (replaced wholesale + revalidated), metadata, tags, icon_emoji / icon_attachment_id (or clear_* to drop the override). Pass at least one field. Returns the full new pane state + an updated_fields array. To swap the HTML/schemas, use upgrade_pane instead.",
796
+ inputSchema: updatePaneShape,
797
+ handler: async (client, args) => {
798
+ try {
799
+ const body = {};
800
+ if (args["ttl_seconds"] !== undefined &&
801
+ args["expires_at"] !== undefined) {
802
+ return invalidArgs("ttl_seconds and expires_at are mutually exclusive");
803
+ }
804
+ if (args["ttl_seconds"] !== undefined)
805
+ body["ttl"] = args["ttl_seconds"];
806
+ if (args["expires_at"] !== undefined)
807
+ body["expires_at"] = args["expires_at"];
808
+ if (args["title"] !== undefined)
809
+ body["title"] = args["title"];
810
+ if (args["preamble"] !== undefined)
811
+ body["preamble"] = args["preamble"];
812
+ if (args["input_data"] !== undefined)
813
+ body["input_data"] = args["input_data"];
814
+ if (args["metadata"] !== undefined)
815
+ body["metadata"] = args["metadata"];
816
+ if (args["tags"] !== undefined)
817
+ body["tags"] = args["tags"];
818
+ if (args["icon_emoji"] !== undefined && args["clear_icon_emoji"]) {
819
+ return invalidArgs("icon_emoji and clear_icon_emoji are mutually exclusive");
820
+ }
821
+ if (args["icon_emoji"] !== undefined)
822
+ body["icon_emoji"] = args["icon_emoji"];
823
+ if (args["clear_icon_emoji"])
824
+ body["icon_emoji"] = null;
825
+ if (args["icon_attachment_id"] !== undefined &&
826
+ args["clear_icon_attachment_id"]) {
827
+ return invalidArgs("icon_attachment_id and clear_icon_attachment_id are mutually exclusive");
828
+ }
829
+ if (args["icon_attachment_id"] !== undefined)
830
+ body["icon_attachment_id"] = args["icon_attachment_id"];
831
+ if (args["clear_icon_attachment_id"])
832
+ body["icon_attachment_id"] = null;
833
+ if (Object.keys(body).length === 0) {
834
+ return invalidArgs("pass at least one field to update");
835
+ }
836
+ const res = await client.updatePane(String(args["pane_id"]), body);
837
+ return jsonResult(res);
838
+ }
839
+ catch (e) {
840
+ return errorResult(e);
841
+ }
842
+ },
843
+ },
844
+ {
845
+ name: "upgrade_pane",
846
+ description: "Re-pin a LIVE pane to another version of its SAME template (POST /upgrade) — swap the HTML (design) and event/input/record schemas in place. The human keeps the same URL; no new pane is created. Use after appending a new template version with the `template` tool (action: version). By default a strict schema-compat gate refuses an upgrade that would narrow the schema (returns schema_incompatible_upgrade + details.breaks); pass force:true to apply anyway. Returns { pane_id, template_version, upgraded, breaks, compat }.",
847
+ inputSchema: upgradePaneShape,
848
+ handler: async (client, args) => {
849
+ try {
850
+ const opts = {};
851
+ if (args["template_version"] !== undefined)
852
+ opts.template_version = args["template_version"];
853
+ if (args["force"])
854
+ opts.compat = "force";
855
+ return jsonResult(await client.upgradePane(String(args["pane_id"]), opts));
856
+ }
857
+ catch (e) {
858
+ return errorResult(e);
859
+ }
860
+ },
861
+ },
862
+ {
863
+ name: "list_panes",
864
+ description: "Enumerate YOUR agent's panes (newest first). Use it to find a pane_id you lost, audit what's open, or get a cursor for pagination. No secrets in the response (participant tokens are unrecoverable — mint a fresh URL with the participant tool). Filter by status (open|closed|all) or template_id. Returns { items, next_cursor }.",
865
+ inputSchema: listPanesShape,
866
+ handler: async (client, args) => {
867
+ try {
868
+ const opts = {};
869
+ if (args["status"] !== undefined)
870
+ opts["status"] = args["status"];
871
+ if (args["limit"] !== undefined)
872
+ opts["limit"] = args["limit"];
873
+ if (args["cursor"] !== undefined)
874
+ opts["cursor"] = args["cursor"];
875
+ if (args["template_id"] !== undefined)
876
+ opts["template_id"] = args["template_id"];
877
+ return jsonResult(await client.listPanes(opts));
878
+ }
879
+ catch (e) {
880
+ return errorResult(e);
881
+ }
882
+ },
883
+ },
884
+ {
885
+ name: "delete_pane",
886
+ description: "Close/delete a pane (idempotent — an already-closed pane still succeeds). The human's URL stops working. To merely edit a pane keep it alive with update_pane; to recover a soft-deleted pane use the trash tool (action: restore).",
887
+ inputSchema: deletePaneShape,
888
+ handler: async (client, args) => {
889
+ try {
890
+ await client.deletePane(String(args["pane_id"]));
891
+ return jsonResult({ pane_id: args["pane_id"], deleted: true });
892
+ }
893
+ catch (e) {
894
+ return errorResult(e);
895
+ }
896
+ },
897
+ },
898
+ // ----- record CRUD (discrete, hot-path) -----------------------------------
271
899
  {
272
900
  name: "list_records",
273
- description: "List rows in a pane's mutable record collection (e.g. a todo list, shopping list, kanban board, comment thread). Records are the right primitive when the page shows several mutable items and the CURRENT state matters more than the history of edits. Includes tombstones (deleted_at set) so you can observe deletions. Returns { records, next_since, has_more }.",
901
+ description: "List rows in a pane's mutable record collection (todo list, shopping list, kanban board, comment thread). Records are the right primitive when the page shows several mutable items and the CURRENT state matters more than the history. This also doubles as the POLL/watch for records (no streaming in MCP): pass the prior next_since to fetch only newer/changed rows. include_tombstones:true surfaces deletions. Returns { records, next_since, has_more }.",
274
902
  inputSchema: listRecordsShape,
275
903
  handler: async (client, args) => {
276
904
  try {
@@ -278,7 +906,31 @@ export const TOOLS = [
278
906
  since: args["since"],
279
907
  limit: args["limit"],
280
908
  });
281
- return jsonResult(out);
909
+ const records = args["include_tombstones"]
910
+ ? out.records
911
+ : out.records.filter((r) => r.deleted_at === null);
912
+ return jsonResult({
913
+ records,
914
+ next_since: out.next_since,
915
+ has_more: out.has_more,
916
+ });
917
+ }
918
+ catch (e) {
919
+ return errorResult(e);
920
+ }
921
+ },
922
+ },
923
+ {
924
+ name: "get_record",
925
+ description: "Fetch a single record row by its key from a pane collection (scans the collection — fine for a one-off lookup, not a hot loop). Returns { record } or an isError record_not_found.",
926
+ inputSchema: getRecordShape,
927
+ handler: async (client, args) => {
928
+ try {
929
+ const row = await client.getRecord(String(args["pane_id"]), String(args["collection"]), String(args["record_key"]));
930
+ if (!row) {
931
+ return invalidArgs(`no record at key '${args["record_key"]}' in collection '${args["collection"]}'`);
932
+ }
933
+ return jsonResult({ record: row });
282
934
  }
283
935
  catch (e) {
284
936
  return errorResult(e);
@@ -296,8 +948,7 @@ export const TOOLS = [
296
948
  };
297
949
  if (args["record_key"] !== undefined)
298
950
  body.record_key = String(args["record_key"]);
299
- const out = await client.upsertRecord(String(args["pane_id"]), String(args["collection"]), body);
300
- return jsonResult(out);
951
+ return jsonResult(await client.upsertRecord(String(args["pane_id"]), String(args["collection"]), body));
301
952
  }
302
953
  catch (e) {
303
954
  return errorResult(e);
@@ -315,8 +966,7 @@ export const TOOLS = [
315
966
  };
316
967
  if (args["if_match"] !== undefined)
317
968
  body.if_match = args["if_match"];
318
- const out = await client.updateRecord(String(args["pane_id"]), String(args["collection"]), String(args["record_key"]), body);
319
- return jsonResult(out);
969
+ return jsonResult(await client.updateRecord(String(args["pane_id"]), String(args["collection"]), String(args["record_key"]), body));
320
970
  }
321
971
  catch (e) {
322
972
  return errorResult(e);
@@ -325,12 +975,605 @@ export const TOOLS = [
325
975
  },
326
976
  {
327
977
  name: "delete_record",
328
- description: "Soft-delete a row from a pane's record collection. The page sees the deletion live (the row becomes a tombstone in list_records). Returns { deleted: true }.",
978
+ description: "Soft-delete a row from a pane's record collection. The page sees the deletion live (the row becomes a tombstone in list_records). Pass if_match for an optimistic-locked delete. Returns { deleted: true }.",
329
979
  inputSchema: deleteRecordShape,
330
980
  handler: async (client, args) => {
331
981
  try {
332
- await client.deleteRecord(String(args["pane_id"]), String(args["collection"]), String(args["record_key"]));
333
- return jsonResult({ deleted: true });
982
+ await client.deleteRecord(String(args["pane_id"]), String(args["collection"]), String(args["record_key"]), args["if_match"] !== undefined
983
+ ? { ifMatch: args["if_match"] }
984
+ : {});
985
+ return jsonResult({ deleted: true, key: args["record_key"] });
986
+ }
987
+ catch (e) {
988
+ return errorResult(e);
989
+ }
990
+ },
991
+ },
992
+ // ----- consolidated management tools --------------------------------------
993
+ {
994
+ name: "template",
995
+ description: "Manage reusable, versioned UI templates (author once, instance many times via create_pane's template_id). ONE tool with an `action` enum: create | version | update | search | list | show | get_version | delete | publish | unpublish | search_public | set_icon. Required fields per action are documented on the `action` parameter. A template is HTML + an event schema (+ optional input/record/template-record schemas); a pane is one use of one version of it.",
996
+ inputSchema: templateShape,
997
+ handler: async (client, args) => {
998
+ const action = String(args["action"]);
999
+ try {
1000
+ switch (action) {
1001
+ case "create": {
1002
+ if (str(args, "name") === undefined ||
1003
+ str(args, "html") === undefined) {
1004
+ return invalidArgs("create requires `name` and `html`");
1005
+ }
1006
+ const req = {
1007
+ name: args["name"],
1008
+ type: args["template_type"] ?? "html-inline",
1009
+ source: args["html"],
1010
+ };
1011
+ for (const k of [
1012
+ "slug",
1013
+ "description",
1014
+ "tags",
1015
+ "event_schema",
1016
+ "input_schema",
1017
+ "record_schema",
1018
+ "template_record_schema",
1019
+ "icon_emoji",
1020
+ ]) {
1021
+ if (args[k] !== undefined)
1022
+ req[k] = args[k];
1023
+ }
1024
+ const res = await client.createArtifact(req);
1025
+ return jsonResult({
1026
+ template_id: res.template_id,
1027
+ slug: str(args, "slug") ?? null,
1028
+ version: res.version,
1029
+ });
1030
+ }
1031
+ case "version": {
1032
+ if (str(args, "id") === undefined ||
1033
+ str(args, "html") === undefined) {
1034
+ return invalidArgs("version requires `id` and `html`");
1035
+ }
1036
+ const req = {
1037
+ type: args["template_type"] ?? "html-inline",
1038
+ source: args["html"],
1039
+ };
1040
+ for (const k of [
1041
+ "event_schema",
1042
+ "input_schema",
1043
+ "record_schema",
1044
+ "template_record_schema",
1045
+ ]) {
1046
+ if (args[k] !== undefined)
1047
+ req[k] = args[k];
1048
+ }
1049
+ return jsonResult(await client.createArtifactVersion(String(args["id"]), req));
1050
+ }
1051
+ case "update": {
1052
+ if (str(args, "id") === undefined)
1053
+ return invalidArgs("update requires `id`");
1054
+ const meta = {};
1055
+ for (const k of ["name", "slug", "description", "tags"]) {
1056
+ if (args[k] !== undefined)
1057
+ meta[k] = args[k];
1058
+ }
1059
+ if (Object.keys(meta).length === 0) {
1060
+ return invalidArgs("update needs at least one of name/slug/description/tags");
1061
+ }
1062
+ return jsonResult(await client.updateArtifact(String(args["id"]), meta));
1063
+ }
1064
+ case "search":
1065
+ return jsonResult(await client.searchArtifacts(str(args, "query")));
1066
+ case "list":
1067
+ return jsonResult(await client.searchArtifacts());
1068
+ case "show":
1069
+ if (str(args, "id") === undefined)
1070
+ return invalidArgs("show requires `id`");
1071
+ return jsonResult(await client.getArtifact(String(args["id"])));
1072
+ case "get_version": {
1073
+ if (str(args, "id") === undefined ||
1074
+ args["version"] === undefined) {
1075
+ return invalidArgs("get_version requires `id` and `version`");
1076
+ }
1077
+ return jsonResult(await client.getArtifactVersion(String(args["id"]), args["version"]));
1078
+ }
1079
+ case "delete": {
1080
+ if (str(args, "id") === undefined)
1081
+ return invalidArgs("delete requires `id`");
1082
+ if (args["confirm"] !== true) {
1083
+ return invalidArgs("delete is destructive (removes the template + all versions) — pass confirm:true");
1084
+ }
1085
+ await client.deleteArtifact(String(args["id"]));
1086
+ return jsonResult({ template: args["id"], deleted: true });
1087
+ }
1088
+ case "publish": {
1089
+ if (str(args, "id") === undefined)
1090
+ return invalidArgs("publish requires `id`");
1091
+ const body = {};
1092
+ if (args["scopes"] !== undefined)
1093
+ body.scopes = args["scopes"];
1094
+ return jsonResult(await client.publishTemplate(String(args["id"]), body));
1095
+ }
1096
+ case "unpublish":
1097
+ if (str(args, "id") === undefined)
1098
+ return invalidArgs("unpublish requires `id`");
1099
+ return jsonResult(await client.unpublishTemplate(String(args["id"])));
1100
+ case "search_public": {
1101
+ const opts = {};
1102
+ if (args["limit"] !== undefined)
1103
+ opts.limit = args["limit"];
1104
+ if (args["offset"] !== undefined)
1105
+ opts.offset = args["offset"];
1106
+ return jsonResult(await client.searchPublicTemplates(str(args, "query"), opts));
1107
+ }
1108
+ case "set_icon": {
1109
+ if (str(args, "id") === undefined)
1110
+ return invalidArgs("set_icon requires `id`");
1111
+ const id = String(args["id"]);
1112
+ const hasEmoji = str(args, "icon_emoji") !== undefined;
1113
+ const hasAttachment = str(args, "icon_attachment_id") !== undefined;
1114
+ const clear = args["clear"] === true;
1115
+ const chosen = [hasEmoji, hasAttachment, clear].filter(Boolean).length;
1116
+ if (chosen !== 1) {
1117
+ return invalidArgs("set_icon needs exactly one of icon_emoji, icon_attachment_id, or clear:true");
1118
+ }
1119
+ const meta = clear
1120
+ ? { icon_emoji: null, icon_attachment_id: null }
1121
+ : hasEmoji
1122
+ ? { icon_emoji: args["icon_emoji"] }
1123
+ : { icon_attachment_id: args["icon_attachment_id"] };
1124
+ return jsonResult(await client.updateArtifact(id, meta));
1125
+ }
1126
+ default:
1127
+ return invalidArgs(`unknown template action '${action}'`);
1128
+ }
1129
+ }
1130
+ catch (e) {
1131
+ return errorResult(e);
1132
+ }
1133
+ },
1134
+ },
1135
+ {
1136
+ name: "template_records",
1137
+ description: "CRUD for TEMPLATE-level record collections — owner-curated content anchored to a template head and visible to every pane derived from any of its versions (vs per-pane records, which are the discrete record tools). ONE tool with an `action` enum: list | get | upsert | update | delete | delete_collection. The template version must declare the collection via template_record_schema (set it with the `template` tool first).",
1138
+ inputSchema: templateRecordsShape,
1139
+ handler: async (client, args) => {
1140
+ const action = String(args["action"]);
1141
+ const templateId = String(args["template_id"]);
1142
+ const collection = String(args["collection"]);
1143
+ try {
1144
+ switch (action) {
1145
+ case "list": {
1146
+ const out = await client.listTemplateRecords(templateId, collection, {
1147
+ since: args["since"],
1148
+ limit: args["limit"],
1149
+ });
1150
+ const records = args["include_tombstones"]
1151
+ ? out.records
1152
+ : out.records.filter((r) => r.deleted_at === null);
1153
+ return jsonResult({
1154
+ records,
1155
+ next_since: out.next_since,
1156
+ has_more: out.has_more,
1157
+ });
1158
+ }
1159
+ case "get": {
1160
+ if (str(args, "record_key") === undefined)
1161
+ return invalidArgs("get requires `record_key`");
1162
+ const row = await client.getTemplateRecord(templateId, collection, String(args["record_key"]));
1163
+ if (!row) {
1164
+ return invalidArgs(`no template record at key '${args["record_key"]}' in '${collection}'`);
1165
+ }
1166
+ return jsonResult({ record: row });
1167
+ }
1168
+ case "upsert": {
1169
+ if (args["data"] === undefined)
1170
+ return invalidArgs("upsert requires `data`");
1171
+ const body = {
1172
+ data: args["data"],
1173
+ };
1174
+ if (str(args, "record_key") !== undefined)
1175
+ body.record_key = String(args["record_key"]);
1176
+ return jsonResult(await client.upsertTemplateRecord(templateId, collection, body));
1177
+ }
1178
+ case "update": {
1179
+ if (str(args, "record_key") === undefined ||
1180
+ args["data"] === undefined)
1181
+ return invalidArgs("update requires `record_key` and `data`");
1182
+ const body = {
1183
+ data: args["data"],
1184
+ };
1185
+ if (args["if_match"] !== undefined)
1186
+ body.if_match = args["if_match"];
1187
+ return jsonResult(await client.updateTemplateRecord(templateId, collection, String(args["record_key"]), body));
1188
+ }
1189
+ case "delete": {
1190
+ if (str(args, "record_key") === undefined)
1191
+ return invalidArgs("delete requires `record_key`");
1192
+ await client.deleteTemplateRecord(templateId, collection, String(args["record_key"]), args["if_match"] !== undefined
1193
+ ? { ifMatch: args["if_match"] }
1194
+ : {});
1195
+ return jsonResult({ deleted: true, key: args["record_key"] });
1196
+ }
1197
+ case "delete_collection": {
1198
+ if (args["confirm"] !== true) {
1199
+ return invalidArgs("delete_collection drops the whole collection — pass confirm:true");
1200
+ }
1201
+ await client.deleteTemplateRecordCollection(templateId, collection);
1202
+ return jsonResult({ deleted: true, collection });
1203
+ }
1204
+ default:
1205
+ return invalidArgs(`unknown template_records action '${action}'`);
1206
+ }
1207
+ }
1208
+ catch (e) {
1209
+ return errorResult(e);
1210
+ }
1211
+ },
1212
+ },
1213
+ {
1214
+ name: "participant",
1215
+ description: "Manage a pane's participant URLs (recovery + leak-containment). ONE tool with an `action` enum: list | new | revoke. Use `new` when you lost the original URL (the plaintext token is returned ONCE — save it). Token URLs are stored hashed and cannot be recovered.",
1216
+ inputSchema: participantShape,
1217
+ handler: async (client, args) => {
1218
+ const action = String(args["action"]);
1219
+ const paneId = String(args["pane_id"]);
1220
+ try {
1221
+ switch (action) {
1222
+ case "list":
1223
+ return jsonResult(await client.listParticipants(paneId));
1224
+ case "new":
1225
+ return jsonResult(await client.mintParticipant(paneId));
1226
+ case "revoke":
1227
+ if (str(args, "participant_id") === undefined)
1228
+ return invalidArgs("revoke requires `participant_id`");
1229
+ await client.revokeParticipant(paneId, String(args["participant_id"]));
1230
+ return jsonResult({
1231
+ pane_id: paneId,
1232
+ participant_id: args["participant_id"],
1233
+ revoked: true,
1234
+ });
1235
+ default:
1236
+ return invalidArgs(`unknown participant action '${action}'`);
1237
+ }
1238
+ }
1239
+ catch (e) {
1240
+ return errorResult(e);
1241
+ }
1242
+ },
1243
+ },
1244
+ {
1245
+ name: "share",
1246
+ description: "Identity sharing on a pane (layered on top of participant tokens). ONE tool with an `action` enum: list (access_mode + grants) | invite (a human by email, role participant|viewer) | set_access (the /p access mode: invite_only|link|public) | revoke (one grant by id). Token (/s/<token>) links are independent of access_mode and keep working.",
1247
+ inputSchema: shareShape,
1248
+ handler: async (client, args) => {
1249
+ const action = String(args["action"]);
1250
+ const paneId = String(args["pane_id"]);
1251
+ try {
1252
+ switch (action) {
1253
+ case "list":
1254
+ return jsonResult(await client.listGrants(paneId));
1255
+ case "invite": {
1256
+ if (str(args, "email") === undefined)
1257
+ return invalidArgs("invite requires `email`");
1258
+ return jsonResult(await client.createGrant(paneId, {
1259
+ email: String(args["email"]),
1260
+ ...(args["role"]
1261
+ ? { role: args["role"] }
1262
+ : {}),
1263
+ }));
1264
+ }
1265
+ case "set_access":
1266
+ if (str(args, "access_mode") === undefined)
1267
+ return invalidArgs("set_access requires `access_mode`");
1268
+ return jsonResult(await client.setPaneVisibility(paneId, args["access_mode"]));
1269
+ case "revoke":
1270
+ if (str(args, "grant_id") === undefined)
1271
+ return invalidArgs("revoke requires `grant_id`");
1272
+ await client.revokeGrant(paneId, String(args["grant_id"]));
1273
+ return jsonResult({
1274
+ pane_id: paneId,
1275
+ grant_id: args["grant_id"],
1276
+ revoked: true,
1277
+ });
1278
+ default:
1279
+ return invalidArgs(`unknown share action '${action}'`);
1280
+ }
1281
+ }
1282
+ catch (e) {
1283
+ return errorResult(e);
1284
+ }
1285
+ },
1286
+ },
1287
+ {
1288
+ name: "attachments",
1289
+ description: "Binary attachments (images, PDFs, audio, video) referenced from event payloads / input_data via `format: pane-attachment-id`. ONE tool with an `action` enum: upload | download | show | list | delete | mint_token | revoke_token | list_tokens. upload reads an ABSOLUTE file_path; download writes to an ABSOLUTE out_path (or returns base64). Scope an upload to agent (default, reusable), pane, or template. mint_token returns a /b/<token> capability URL (ONCE) a browser can GET without your API key.",
1290
+ inputSchema: attachmentsShape,
1291
+ handler: async (client, args) => {
1292
+ const action = String(args["action"]);
1293
+ try {
1294
+ switch (action) {
1295
+ case "upload": {
1296
+ const filePath = str(args, "file_path");
1297
+ if (filePath === undefined)
1298
+ return invalidArgs("upload requires `file_path` (absolute)");
1299
+ const scope = (str(args, "scope") ?? "agent");
1300
+ if (scope === "pane" && str(args, "pane_id") === undefined)
1301
+ return invalidArgs("scope=pane requires `pane_id`");
1302
+ if (scope === "template" && str(args, "template_id") === undefined)
1303
+ return invalidArgs("scope=template requires `template_id`");
1304
+ let bytes;
1305
+ try {
1306
+ bytes = readFileSync(filePath);
1307
+ }
1308
+ catch (e) {
1309
+ return invalidArgs(`failed to read file_path '${filePath}': ${e instanceof Error ? e.message : String(e)}`);
1310
+ }
1311
+ const ref = await client.uploadBlob(bytes, {
1312
+ scope,
1313
+ paneId: str(args, "pane_id"),
1314
+ templateId: str(args, "template_id"),
1315
+ filename: str(args, "filename") ?? basename(filePath),
1316
+ mime: str(args, "mime"),
1317
+ });
1318
+ return jsonResult(ref);
1319
+ }
1320
+ case "download": {
1321
+ if (str(args, "attachment_id") === undefined)
1322
+ return invalidArgs("download requires `attachment_id`");
1323
+ const buf = await client.downloadBlob(String(args["attachment_id"]));
1324
+ const outPath = str(args, "out_path");
1325
+ if (outPath !== undefined) {
1326
+ try {
1327
+ writeFileSync(outPath, Buffer.from(buf));
1328
+ }
1329
+ catch (e) {
1330
+ return invalidArgs(`failed to write out_path '${outPath}': ${e instanceof Error ? e.message : String(e)}`);
1331
+ }
1332
+ return jsonResult({
1333
+ attachment_id: args["attachment_id"],
1334
+ written: outPath,
1335
+ bytes: buf.byteLength,
1336
+ });
1337
+ }
1338
+ return jsonResult({
1339
+ attachment_id: args["attachment_id"],
1340
+ bytes: buf.byteLength,
1341
+ base64: Buffer.from(buf).toString("base64"),
1342
+ });
1343
+ }
1344
+ case "show":
1345
+ if (str(args, "attachment_id") === undefined)
1346
+ return invalidArgs("show requires `attachment_id`");
1347
+ return jsonResult(await client.getBlob(String(args["attachment_id"])));
1348
+ case "list": {
1349
+ const opts = {};
1350
+ if (str(args, "cursor") !== undefined)
1351
+ opts.cursor = String(args["cursor"]);
1352
+ if (args["limit"] !== undefined)
1353
+ opts.limit = args["limit"];
1354
+ return jsonResult(await client.listBlobs(opts));
1355
+ }
1356
+ case "delete":
1357
+ if (str(args, "attachment_id") === undefined)
1358
+ return invalidArgs("delete requires `attachment_id`");
1359
+ return jsonResult(await client.deleteBlob(String(args["attachment_id"])));
1360
+ case "mint_token": {
1361
+ if (str(args, "attachment_id") === undefined)
1362
+ return invalidArgs("mint_token requires `attachment_id`");
1363
+ return jsonResult(await client.mintBlobToken(String(args["attachment_id"]), {
1364
+ ttlSeconds: args["ttl_seconds"],
1365
+ once: args["once"] === true,
1366
+ }));
1367
+ }
1368
+ case "revoke_token":
1369
+ if (str(args, "attachment_id") === undefined ||
1370
+ str(args, "token_id") === undefined)
1371
+ return invalidArgs("revoke_token requires `attachment_id` and `token_id`");
1372
+ return jsonResult(await client.revokeBlobToken(String(args["attachment_id"]), String(args["token_id"])));
1373
+ case "list_tokens":
1374
+ if (str(args, "attachment_id") === undefined)
1375
+ return invalidArgs("list_tokens requires `attachment_id`");
1376
+ return jsonResult(await client.listBlobTokens(String(args["attachment_id"])));
1377
+ default:
1378
+ return invalidArgs(`unknown attachments action '${action}'`);
1379
+ }
1380
+ }
1381
+ catch (e) {
1382
+ return errorResult(e);
1383
+ }
1384
+ },
1385
+ },
1386
+ {
1387
+ name: "taste",
1388
+ description: "Read / write / clear the agent's freeform UI taste notes (a small markdown document of presentation preferences learned from human feedback — 'denser layout', 'no rounded corners'). ONE tool with an `action` enum: get | set | clear. Call `get` BEFORE generating a pane so prior feedback shapes the output; `set` does a whole-document replace (not append). Keep entries about UI/presentation only.",
1389
+ inputSchema: tasteShape,
1390
+ handler: async (client, args) => {
1391
+ const action = String(args["action"]);
1392
+ try {
1393
+ switch (action) {
1394
+ case "get":
1395
+ return jsonResult(await client.getTaste());
1396
+ case "set": {
1397
+ const taste = str(args, "taste");
1398
+ if (taste === undefined || taste.trim() === "")
1399
+ return invalidArgs("set requires non-empty `taste` (use clear to delete the notes)");
1400
+ return jsonResult(await client.setTaste(taste));
1401
+ }
1402
+ case "clear":
1403
+ await client.clearTaste();
1404
+ return jsonResult({ cleared: true });
1405
+ default:
1406
+ return invalidArgs(`unknown taste action '${action}'`);
1407
+ }
1408
+ }
1409
+ catch (e) {
1410
+ return errorResult(e);
1411
+ }
1412
+ },
1413
+ },
1414
+ {
1415
+ name: "key",
1416
+ description: "Inspect or revoke the calling agent's API key. ONE tool with an `action` enum: list (key info — agent_id, key_prefix, timestamps) | revoke (self-destruct the agent's OWN key; it stops working immediately and is irreversible — pass confirm:true). The relay scopes keys to the caller, so both act only on your own key.",
1417
+ inputSchema: keyShape,
1418
+ handler: async (client, args) => {
1419
+ const action = String(args["action"]);
1420
+ try {
1421
+ switch (action) {
1422
+ case "list":
1423
+ return jsonResult(await client.listKeys());
1424
+ case "revoke": {
1425
+ if (args["confirm"] !== true) {
1426
+ return invalidArgs("revoke is irreversible and stops your key working immediately — pass confirm:true");
1427
+ }
1428
+ const id = (await client.listKeys()).agent_id;
1429
+ await client.revokeKey(id);
1430
+ return jsonResult({ revoked: true, agent_id: id });
1431
+ }
1432
+ default:
1433
+ return invalidArgs(`unknown key action '${action}'`);
1434
+ }
1435
+ }
1436
+ catch (e) {
1437
+ return errorResult(e);
1438
+ }
1439
+ },
1440
+ },
1441
+ {
1442
+ name: "trash",
1443
+ description: "Manage soft-deleted panes + templates. ONE tool with an `action` enum: list | restore (pane id) | restore_template (template id|slug) | purge (pane id) | purge_template (template id|slug). purge bypasses the retention window and is permanent. Soft-deleted rows live in trash until the sweeper reclaims them.",
1444
+ inputSchema: trashShape,
1445
+ handler: async (client, args) => {
1446
+ const action = String(args["action"]);
1447
+ try {
1448
+ switch (action) {
1449
+ case "list":
1450
+ return jsonResult(await client.listTrash());
1451
+ case "restore":
1452
+ if (str(args, "id") === undefined)
1453
+ return invalidArgs("restore requires `id` (pane id)");
1454
+ await client.restorePane(String(args["id"]));
1455
+ return jsonResult({ pane_id: args["id"], restored: true });
1456
+ case "restore_template":
1457
+ if (str(args, "id") === undefined)
1458
+ return invalidArgs("restore_template requires `id` (template id|slug)");
1459
+ await client.restoreTemplate(String(args["id"]));
1460
+ return jsonResult({ template_id: args["id"], restored: true });
1461
+ case "purge":
1462
+ if (str(args, "id") === undefined)
1463
+ return invalidArgs("purge requires `id` (pane id)");
1464
+ await client.permanentDeletePane(String(args["id"]));
1465
+ return jsonResult({ pane_id: args["id"], purged: true });
1466
+ case "purge_template":
1467
+ if (str(args, "id") === undefined)
1468
+ return invalidArgs("purge_template requires `id` (template id|slug)");
1469
+ await client.permanentDeleteTemplate(String(args["id"]));
1470
+ return jsonResult({ template_id: args["id"], purged: true });
1471
+ default:
1472
+ return invalidArgs(`unknown trash action '${action}'`);
1473
+ }
1474
+ }
1475
+ catch (e) {
1476
+ return errorResult(e);
1477
+ }
1478
+ },
1479
+ },
1480
+ {
1481
+ name: "feedback",
1482
+ description: "Send or list feedback to the relay operator. ONE tool with an `action` enum: create (a bug|feature|note with a message, optional pane_id) | list (the agent's own submissions, newest first, paginated by before).",
1483
+ inputSchema: feedbackShape,
1484
+ handler: async (client, args) => {
1485
+ const action = String(args["action"]);
1486
+ try {
1487
+ switch (action) {
1488
+ case "create": {
1489
+ if (str(args, "type") === undefined ||
1490
+ str(args, "message") === undefined)
1491
+ return invalidArgs("create requires `type` and `message`");
1492
+ return jsonResult(await client.submitFeedback({
1493
+ type: args["type"],
1494
+ message: String(args["message"]),
1495
+ ...(str(args, "pane_id") !== undefined
1496
+ ? { paneId: String(args["pane_id"]) }
1497
+ : {}),
1498
+ }));
1499
+ }
1500
+ case "list": {
1501
+ const opts = {};
1502
+ if (args["limit"] !== undefined)
1503
+ opts.limit = args["limit"];
1504
+ if (str(args, "before") !== undefined)
1505
+ opts.before = String(args["before"]);
1506
+ return jsonResult(await client.listFeedback(opts));
1507
+ }
1508
+ default:
1509
+ return invalidArgs(`unknown feedback action '${action}'`);
1510
+ }
1511
+ }
1512
+ catch (e) {
1513
+ return errorResult(e);
1514
+ }
1515
+ },
1516
+ },
1517
+ {
1518
+ name: "agent",
1519
+ description: "Agent identity + binding. ONE tool with an `action` enum: whoami (the resolved relay URL, active profile, whether a key is configured — no network, no secrets) | claim (bind this agent to a human via a one-shot claim code from their Settings UI; one-way) | logout (clear the locally-saved key/profile; does NOT revoke it on the relay — use the `key` tool's revoke for that).",
1520
+ inputSchema: agentShape,
1521
+ handler: async (client, args) => {
1522
+ const action = String(args["action"]);
1523
+ try {
1524
+ switch (action) {
1525
+ case "whoami":
1526
+ // No network — pure local config introspection.
1527
+ return jsonResult(describeActiveConfig());
1528
+ case "claim":
1529
+ if (str(args, "code") === undefined)
1530
+ return invalidArgs("claim requires `code`");
1531
+ return jsonResult(await client.claimAgent(String(args["code"])));
1532
+ case "logout":
1533
+ return jsonResult(clearActiveProfile());
1534
+ default:
1535
+ return invalidArgs(`unknown agent action '${action}'`);
1536
+ }
1537
+ }
1538
+ catch (e) {
1539
+ return errorResult(e);
1540
+ }
1541
+ },
1542
+ },
1543
+ {
1544
+ name: "run_query",
1545
+ description: "Run read-only SQL over YOUR scoped data (panes, records, events) — the relay scopes every row to panes you own. Use it to summarise activity, find panes/records by content, or build a report. Tables + columns and JSON projection operators are documented on the `sql` parameter. Default output is { columns, rows, truncated, scope, elapsed_ms } (format:json); csv/tsv/table render the rows as text. Capped at 10,000 rows; 10s timeout.",
1546
+ inputSchema: runQueryShape,
1547
+ handler: async (client, args) => {
1548
+ try {
1549
+ const result = await client.query(String(args["sql"]), str(args, "pane_id") !== undefined
1550
+ ? { paneId: String(args["pane_id"]) }
1551
+ : {});
1552
+ const format = (str(args, "format") ?? "json");
1553
+ if (format === "json")
1554
+ return jsonResult(result);
1555
+ if (format === "table")
1556
+ return textResult(renderTable(result));
1557
+ return textResult(renderDelimited(result, format === "csv" ? "," : "\t"));
1558
+ }
1559
+ catch (e) {
1560
+ return errorResult(e);
1561
+ }
1562
+ },
1563
+ },
1564
+ {
1565
+ name: "get_skill",
1566
+ description: "Fetch the relay's auto-updating SKILL.md (the full Pane usage guide) — UNAUTHENTICATED, needs no API key. Call this to self-teach the Pane workflow (events vs records, schema grammars, the poll loop) before driving the other tools. Pass version_only:true to get just the relay's skill version string (to check if a cached copy is stale).",
1567
+ inputSchema: getSkillShape,
1568
+ handler: async (_client, args) => {
1569
+ try {
1570
+ const url = resolveUrl();
1571
+ if (args["version_only"]) {
1572
+ const { version } = await fetchSkill(url, { version: true });
1573
+ return jsonResult({ version });
1574
+ }
1575
+ const { markdown } = await fetchSkill(url);
1576
+ return textResult(markdown ?? "");
334
1577
  }
335
1578
  catch (e) {
336
1579
  return errorResult(e);
@@ -338,3 +1581,60 @@ export const TOOLS = [
338
1581
  },
339
1582
  },
340
1583
  ];
1584
+ function cell(v) {
1585
+ if (v === null || v === undefined)
1586
+ return "";
1587
+ if (typeof v === "string")
1588
+ return v;
1589
+ if (typeof v === "number" || typeof v === "boolean")
1590
+ return String(v);
1591
+ if (typeof v === "bigint")
1592
+ return v.toString();
1593
+ try {
1594
+ return JSON.stringify(v);
1595
+ }
1596
+ catch {
1597
+ return String(v);
1598
+ }
1599
+ }
1600
+ function escapeDelimited(sep, value) {
1601
+ if (value.includes(sep) ||
1602
+ value.includes('"') ||
1603
+ value.includes("\n") ||
1604
+ value.includes("\r")) {
1605
+ return `"${value.replace(/"/g, '""')}"`;
1606
+ }
1607
+ return value;
1608
+ }
1609
+ function renderDelimited(result, sep) {
1610
+ const lines = [];
1611
+ lines.push(result.columns.map((c) => escapeDelimited(sep, c)).join(sep));
1612
+ for (const row of result.rows) {
1613
+ lines.push(row.map((c) => escapeDelimited(sep, cell(c))).join(sep));
1614
+ }
1615
+ if (result.truncated)
1616
+ lines.push(`# truncated: capped at ${result.rows.length} rows`);
1617
+ return lines.join("\n") + "\n";
1618
+ }
1619
+ function renderTable(result) {
1620
+ if (result.columns.length === 0)
1621
+ return "(no columns)\n";
1622
+ const COL_MAX = 80;
1623
+ const grid = [result.columns.slice()];
1624
+ for (const row of result.rows)
1625
+ grid.push(row.map(cell));
1626
+ const widths = result.columns.map((_, ci) => Math.min(COL_MAX, Math.max(...grid.map((r) => (r[ci] ?? "").length))));
1627
+ const fmt = (cells) => cells
1628
+ .map((c, i) => {
1629
+ const w = widths[i] ?? 0;
1630
+ const s = c.length > w ? c.slice(0, Math.max(0, w - 1)) + "…" : c;
1631
+ return s.padEnd(w);
1632
+ })
1633
+ .join(" │ ");
1634
+ const rule = "─".repeat(widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * 3);
1635
+ const out = [fmt(grid[0]), rule];
1636
+ for (let i = 1; i < grid.length; i++)
1637
+ out.push(fmt(grid[i]));
1638
+ out.push(`\n${result.rows.length} row${result.rows.length === 1 ? "" : "s"}${result.truncated ? " (truncated; cap = 10000)" : ""} · scope: ${result.scope.kind} (${result.scope.pane_count} panes) · ${result.elapsed_ms}ms`);
1639
+ return out.join("\n") + "\n";
1640
+ }