@repogo/plugins 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,11 +3,12 @@
3
3
  Plugins are small apps that run on your environment, the machine the RepoGo
4
4
  host runs on. A plugin keeps its own data, does work with your tools and APIs,
5
5
  and is reached two ways: agents call its methods as tools, and cards on your
6
- phone draw its data live and run its methods when you tap.
6
+ phone draw its data live and run its methods when you tap. A method may also
7
+ opt into plain HTTP, for this machine's containers or the public.
7
8
 
8
9
  - [Quick start](#quick-start)
9
10
  - [Where plugins live](#where-plugins-live)
10
- - [Methods](#methods): queries, mutations, streams, cached queries
11
+ - [Methods](#methods): reads, writes, destructive methods, streams, cached reads, [serving over HTTP](#serving-over-http)
11
12
  - [Data](#data): namespaces, collections, kv, streams, buckets, uploads
12
13
  - [Workflows](#workflows)
13
14
  - [Crons](#crons)
@@ -15,28 +16,28 @@ phone draw its data live and run its methods when you tap.
15
16
  - [The context](#the-context)
16
17
  - [Events](#events)
17
18
  - [How cards and agents reach a plugin](#how-cards-and-agents-reach-a-plugin)
18
- - [Errors and confirmations](#errors-and-confirmations)
19
+ - [Errors and approvals](#errors-and-approvals)
19
20
  - [Testing](#testing)
20
21
 
21
22
  ## Quick start
22
23
 
23
24
  ```ts
24
- // .repogo/plugins/notes/app.ts
25
+ // ~/.repogo/plugins/notes/app.ts
25
26
  import { definePlugin, method, z } from "@repogo/plugins";
26
27
 
27
28
  export default definePlugin({
28
- description: "Notes for this project",
29
- namespace: "myproject.notes",
29
+ description: "Notes for any project",
30
+ namespace: "notes",
30
31
  collections: { notes: z.object({ text: z.string(), done: z.boolean().default(false) }) },
31
32
  methods: {
32
33
  open: method(z.object({}), async (_, ctx) => ctx.collections.notes.list({ where: { done: false } }), {
33
- kind: "query",
34
+ effect: "read",
34
35
  description: "Notes not done yet, newest first",
35
36
  }),
36
37
  clear: method(z.object({}), async (_, ctx) => {
37
38
  for (const note of await ctx.collections.notes.list({ limit: 500 })) await ctx.collections.notes.delete(note.id);
38
39
  return null;
39
- }, { confirm: "Delete every note?" }),
40
+ }, { effect: "destructive", approve: "Delete every note?" }),
40
41
  },
41
42
  });
42
43
  ```
@@ -46,9 +47,9 @@ a minute; at once when something calls it), and it gets:
46
47
 
47
48
  - `notes.open` and `notes.clear`, the methods it wrote;
48
49
  - `notes.notes.list`, `.get`, `.create`, `.update` and `.delete`, generated for its collection;
49
- - a `data.db` in `~/.repogo/plugin-data/myproject.notes/`.
50
+ - a `data.db` in `~/.repogo/plugin-data/notes/`.
50
51
 
51
- Add the package to `.repogo/plugins/package.json` (`bun add @repogo/plugins`) for
52
+ Add the package to `~/.repogo/plugins/package.json` (`bun add @repogo/plugins`) for
52
53
  types and editor help. The host runs plugins with its own copy of this package,
53
54
  the version it shipped with, so a plugin always matches its host. Plugins run on
54
55
  [Bun](https://bun.sh); install it on the environment.
@@ -57,30 +58,43 @@ the version it shipped with, so a plugin always matches its host. Plugins run on
57
58
 
58
59
  | Folder | For | `ctx.root` |
59
60
  | --- | --- | --- |
60
- | `<project>/.repogo/plugins/<name>/app.ts` | One project | The project's checkout |
61
- | `~/.repogo/plugins/<name>/app.ts` | Every project (Vercel, Gmail, builds) | The project that called it |
62
-
63
- A plugin's name is its folder's: lowercase letters, digits and hyphens. A
64
- project plugin hides an environment one of the same name. Each folder's plugins
65
- run together in one Worker, so a plugin's blocking code stalls the others in its
66
- folder: use async APIs.
61
+ | `~/.repogo/plugins/<name>/app.ts` | Every project | The project that called it |
62
+
63
+ Plugins live only on the environment, in folders the user put there. A project
64
+ can't ship plugin code: the host never imports a repo's `.repogo/plugins`, so
65
+ cloning or opening a project never runs anything in it. A plugin about one
66
+ project (its keys, its database, its plans) is still an environment plugin: it
67
+ reads that project from `ctx.root`, and says so instead of acting when it's
68
+ called from a project it doesn't fit.
69
+
70
+ A project ships no pages either: the app loads `.ui` and `.html` pages only
71
+ from `~/.repogo/plugins`, so a cloned repo can't put its own page, and the
72
+ script an `.html` page runs, in front of the user. A page about one project
73
+ lives beside its plugin and takes the project as state (`?project=…`).
74
+
75
+ A plugin's name is its folder's: lowercase letters, digits and hyphens. The
76
+ environment's plugins run together in one Worker, so a plugin's blocking code
77
+ stalls the others: use async APIs. They load together too: one that fails to
78
+ load (a throw at import, a destructive method without an `approve`, data without
79
+ a `namespace`, a method named like a generated one) stops every plugin in the
80
+ folder, and each call answers its error, until it's fixed.
67
81
 
68
82
  Pages beside an environment plugin (`pages/*.ui`, or `pages/*.md`) open from
69
83
  any project as `~/.repogo/plugins/<name>/pages/<page>.ui`. A
70
84
  `.ui` page is card markup with no fence; a `.md` page is writing with cards.
71
85
 
72
- In the full iOS app, `~/.repogo/plugins/plugin.ui` supplies a shared Plugins
73
- tab. A project’s `.repogo/plugin.ui` overrides the entire page for that
74
- project; entries are not merged. If neither exists, the tab is absent.
75
- Both are plain card markup without a code fence. Read and preserve existing
86
+ In the full iOS app, `~/.repogo/plugins/plugin.ui` is the Plugins tab in
87
+ every project; a project's `.repogo/plugin.ui` is never read. If it doesn't
88
+ exist, the tab is absent. It is plain card markup without a code fence. Read and preserve existing
76
89
  entries, and link to plugin pages with `page:` actions.
77
90
 
78
- A user-facing plugin needs both its saved page and a link from the applicable `plugin.ui`. Saving a file in `.repogo/tabs` or naming it `index.ui` does not register a project tab. Plugin discovery can list a page without a navigation entry; verify the effective `plugin.ui` links to the existing page.
91
+ A user-facing plugin needs both its saved page and a link from `~/.repogo/plugins/plugin.ui`. Saving a file in a pages folder or naming it `index.ui` does not add it to the tab. Plugin discovery can list a page without a navigation entry; verify `plugin.ui` links to the existing page.
79
92
 
80
93
  ## Methods
81
94
 
82
95
  ```ts
83
- method(input, run, { kind?, description?, output?, confirm? })
96
+ method(input, run, { effect?, description?, output?, approve?, approveUpTo?, http? })
97
+ cached(input, run, { fresh, shared?, timeout?, description?, output?, http? })
84
98
  stream(input, async function* run, { description? })
85
99
  ```
86
100
 
@@ -88,14 +102,19 @@ stream(input, async function* run, { description? })
88
102
  filled in before `run` sees it. `run(input, ctx)` returns the result: anything
89
103
  JSON can carry.
90
104
 
91
- | Kind | Does | Who runs it |
105
+ | Effect | Does | Who runs it |
92
106
  | --- | --- | --- |
93
- | `query` | Only reads; writing throws | Cards on their own, and again whenever data it read changes |
94
- | `mutation` (the default) | Reads and writes | Only when someone acts: a tap, an agent, a cron, other code |
95
- | `stream` | Only reads; yields items as they happen | A card while it shows it |
96
-
97
- Mark every method that only reads as `kind: "query"`: that's what lets a card
98
- show it and keep it current. Give each a `description`; agents read it to choose
107
+ | `read` | Only reads; writing throws | Cards on their own, and again whenever data it read changes |
108
+ | `write` (the default) | Reads and writes | Only when someone acts: a tap, an agent, a cron, other code |
109
+ | `destructive` | Writes what can't be undone; needs an `approve` | Only after the user answers its question |
110
+ | `stream()` | A read that yields items as they happen | A card while it shows it |
111
+
112
+ Mark every method that only reads `effect: "read"`: that's what lets a card
113
+ show it and keep it current. Mark what can't be undone (a delete, a send, a
114
+ deploy) `effect: "destructive"` with an `approve` question; a destructive method
115
+ without one doesn't load. A method with an `approve` asks before it runs, from
116
+ a tap or an agent ([Errors and approvals](#errors-and-approvals)), and never
117
+ runs from a cron. Give each a `description`; agents read it to choose
99
118
  a method. `output`, a zod schema of the result, tells agents and cards its
100
119
  field names.
101
120
 
@@ -108,13 +127,14 @@ or the voice agent reads the whole of one.
108
127
  ```ts
109
128
  open: method(z.object({ limit: z.number().int().min(1).max(100).default(20) }), async ({ limit }, ctx) =>
110
129
  (await ctx.collections.notes.list({ where: { done: false }, limit })).map(({ id, text }) => ({ id, text: text.slice(0, 200) })),
111
- { kind: "query", description: "Notes not done yet, newest first" }),
130
+ { effect: "read", description: "Notes not done yet, newest first" }),
112
131
  ```
113
132
 
114
133
  A **stream** sends each `yield` to the card as it happens, for as long as the
115
134
  card shows it. `ctx.signal` aborts when the card goes, so hand it to what you
116
- start. Give items an `id`. An agent calling a stream gets what arrives within
117
- 5 seconds, at most 200 items.
135
+ start. Give items an `id`. An agent calling a stream, or `ctx.call` on one,
136
+ gets `{ items, done }`: what arrives within 5 seconds, at most 200 items, with
137
+ `done` false when it was cut off.
118
138
 
119
139
  ```ts
120
140
  tail: stream(z.object({ file: z.string() }), async function* ({ file }, ctx) {
@@ -142,13 +162,93 @@ projects: cached(z.object({ limit: z.number().int().min(1).max(200).default(100)
142
162
 
143
163
  - Answers are kept per method, input and project (`ctx.root`). `shared: true`
144
164
  keeps one for every project, for answers that don't depend on it.
145
- - `run` is read-only, like a query's, and has `timeout` (a minute unless it says).
165
+ - `run` is read-only, like any read's, and has `timeout` (a minute unless it says).
146
166
  - A background check that fails keeps the saved answer, goes to the plugin's
147
167
  logs, and is tried again after another `fresh`.
148
168
  - Answers live in the namespace's `data.db`, so a plugin with a cached method
149
169
  needs a `namespace`. An answer over 1 MB is returned but not saved, and one
150
170
  nobody has asked for in 30 days is deleted.
151
171
 
172
+ ### Serving over HTTP
173
+
174
+ Cards, agents and crons reach every method. A method answers plain HTTP too
175
+ when its `http` option names an address, with who may call it there. `method`
176
+ and `cached` take it; a stream or a method with an `approve` is never served,
177
+ since no one is there to watch or answer.
178
+
179
+ ```ts
180
+ today: cached(z.object({}), async () => sales(), { fresh: "5m", http: { device: "none" } }),
181
+ ```
182
+
183
+ | Address | Where | Reached at |
184
+ | --- | --- | --- |
185
+ | `device` | This machine's loopback and its Apple containers' gateways, port 47444; Docker Desktop's containers reach it through `host.docker.internal` | `http://127.0.0.1:47444/<plugin>/<method>` |
186
+ | `public` | The environment's own name, through the tunnel | `https://h-<host id>.repogo.dev/<plugin>/<method>` |
187
+
188
+ | Access | Who may call |
189
+ | --- | --- |
190
+ | `key` | Only a caller sending the plugin's key as `Authorization: Bearer <key>`, which the host checks before the method runs |
191
+ | `none` | Anyone who reaches the address. On `device` the network is the boundary; on `public` it's anyone with the URL, since a method doesn't see the request's headers to check a signature |
192
+
193
+ `http: true` is `{ public: "key" }`. An address a method doesn't name answers
194
+ 404, as do a method that doesn't exist and a missing or wrong key, so callers
195
+ can't learn which plugins are installed.
196
+
197
+ `repogo urls shop` lists each of a plugin's methods served over HTTP with its
198
+ URLs, filled in with this host's id.
199
+
200
+ Each plugin has one key, for all its methods that take one, at both
201
+ addresses. `repogo keys shop` prints it, making it the first time; running it
202
+ again shows the same key. `repogo keys shop --new` replaces it, and the old
203
+ one stops working at once; `repogo keys shop --delete` leaves the methods
204
+ answering no one. RepoGo's tools for agents and a plugin's `ctx.host` can't
205
+ reach keys, though anything that runs commands as you can run `repogo keys`,
206
+ as it can read your plugins' own secrets. The method itself never sees the key.
207
+
208
+ A POST's JSON body is the input as it is. A GET's query is the input, as
209
+ strings, and only a `read` may be called that way:
210
+ link previews and crawlers fetch any URL they're shown, so a write answers
211
+ 405 to a GET. The answer is the result as JSON, or `{ error, fields }` with
212
+ 400, 403 or 404 for a `MethodError` with those codes, in its own words. Any
213
+ other error, such as a failed `fetch`, answers 500 `Internal server error`,
214
+ since its text may hold a token or a path; the host logs the real one. The
215
+ device address takes calls only from this machine and its containers'
216
+ networks, and refuses a browser's, since any web page can reach loopback.
217
+
218
+ A body that isn't JSON answers 400, as does one over 1 MB at the device
219
+ address (413 at the public one), and the public address answers 429 to a
220
+ caller sending too many. A plugin folder that fails to load answers 502. HEAD is a
221
+ GET, and a query key given twice keeps its first value. HTTP calls have no
222
+ project: `ctx.root` is the home folder. `ctx.caller` is `http` for a public call and `device`
223
+ for one at the device address.
224
+
225
+ When an HTTP caller hangs up, or the public address stops waiting after 60
226
+ seconds, `ctx.signal` aborts: hand it to `fetch` and `Bun.spawn` so the work
227
+ stops. The public address runs at most 32 calls at once and answers 503 to
228
+ another; a call counts until its method returns, so one that ignores the
229
+ signal keeps its place until it's done.
230
+
231
+ A method served over HTTP runs with all of `ctx`: one that starts an agent,
232
+ runs an action or calls another method lets its caller do the same. Every
233
+ container on this machine reaches every method open to `device`, and so does
234
+ every account and app on it. Open only what you'd show anyone who can reach
235
+ the address: reads, never a send or a delete.
236
+
237
+ **A plugin elsewhere that stands in for one here.** `GET /<plugin>` at the
238
+ device address lists the methods it serves there with no key, with their
239
+ schemas, and `remotePlugin` builds a plugin from it. In a container, a plugin
240
+ of the same name whose `app.ts` is only this serves the Mac's plugin, and its
241
+ pages, copied or mounted, work unchanged; the code, keys and data stay on the Mac:
242
+
243
+ ```ts
244
+ import { remotePlugin } from "@repogo/plugins";
245
+ export default await remotePlugin("http://192.168.64.1:47444", "shop");
246
+ ```
247
+
248
+ It reads the list when the plugin loads, so it fails to load while the Mac
249
+ can't be reached. A page's button for a method the Mac doesn't serve there
250
+ fails, as it would for any missing method.
251
+
152
252
  ## Data
153
253
 
154
254
  A plugin declares its data in four kinds, each with a schema, and gets typed
@@ -193,7 +293,9 @@ await ctx.collections.tasks.delete(task.id);
193
293
  await ctx.collections.tasks.deleteMany({ where: { status: "done" }, offset: 100, limit: 500 }); // done ones past the newest 100, 500 at a time
194
294
  ```
195
295
 
196
- `list` is newest first by default, at most 100 unless `limit` says (the generated `list` takes up to 500).
296
+ `list` is newest first by default, at most 100 unless `limit` says. The
297
+ generated `list` cards and agents call takes `limit` (up to 500), `offset`,
298
+ `orderBy` and `order` but no `where`: write a `read` method to filter.
197
299
  `where` matches top-level fields exactly; `null` matches a missing one.
198
300
  `orderBy` is `id`, `createdAt`, `updatedAt` or one of the record's own fields, and
199
301
  `offset` passes over that many first. `deleteMany` takes the same options and
@@ -377,7 +479,7 @@ export default definePlugin({
377
479
  await ctx.kv.reports.set("latest", report);
378
480
  return report;
379
481
  });
380
- }, { timeout: "30d", maxOffline: "14d", retention: "30d" }),
482
+ }, { timeout: "30d", maxOffline: "14d", retention: "30d" }), // the defaults
381
483
  },
382
484
  kv: { reports: z.object({ title: z.string() }) },
383
485
  });
@@ -385,7 +487,7 @@ export default definePlugin({
385
487
 
386
488
  For a plugin folder named `reports`, the host generates:
387
489
 
388
- - `reports.report.list({ limit?, status?, cursor? })`: bounded summaries and a next cursor.
490
+ - `reports.report.list({ limit?, status?, cursor? })`: bounded summaries (20 unless `limit` says, at most 100) and a next cursor. A status is `pending`, `running`, `waiting`, `attention`, `completed`, `failed`, `cancelled`, `expired`, `unknown` or `blocked`.
389
491
  - `reports.report.get({ id })`: one summary, input, result, error and the latest 20 operations (steps and sleeps).
390
492
  - `reports.report.start({ input, key? })`: persist and queue a run; returns its summary immediately.
391
493
  - `reports.report.cancel({ id })`: persist cancellation intent and abort active work.
@@ -394,9 +496,9 @@ For a plugin folder named `reports`, the host generates:
394
496
  - `reports.report.restart({ id })`: start a run that needs attention over from the beginning, on the current code.
395
497
 
396
498
  These use the existing plugin methods, `ctx.call`, cards and subscriptions.
397
- The namespace is required. `confirm` on the workflow confirms its start;
499
+ The namespace is required. `approve` on the workflow asks before its start;
398
500
  it does not preapprove later host actions. An unattended workflow refuses
399
- confirmation-gated plugin methods. The calling project and caller
501
+ plugin methods that ask. The calling project and caller
400
502
  are preserved. A start `key` deduplicates within that project, plugin and
401
503
  workflow while its run is retained; reusing it with different input fails.
402
504
 
@@ -428,8 +530,10 @@ with `kind: "sleep"`. Terminal runs have no pending wake time.
428
530
  `attempts` counts the first invocation and defaults to 1. Only steps declared
429
531
  `kind: "query"` may automatically retry; they cannot write SDK data or call
430
532
  mutations. Within query steps, use `ctx.mcp` for read-aware host access;
431
- generic `ctx.host`, notifications and agent starts are refused. Query backoff defaults to full jitter with initial `2s`, factor 2
432
- and maximum `1m`. Import `NonRetryable` and throw it for a permanent failure.
533
+ generic `ctx.host`, notifications and agent starts are refused. A step's `kind`
534
+ defaults to `"mutation"`, and `attempts` is at most 100. Query backoff,
535
+ `backoff: { initial, factor, max }`, defaults to full jitter with initial `2s`,
536
+ factor 2 and maximum `1m`. Import `NonRetryable` and throw it for a permanent failure.
433
537
  Step `timeout` defaults to `5m`. The callback receives `signal`, `attempt`,
434
538
  `attempts`, `runId`, `stepId` and an `idempotencyKey` stable across its attempts.
435
539
  Pass its signal to fetch or subprocess APIs. A timeout aborts cooperatively;
@@ -500,8 +604,8 @@ itself. Each line names a plugin, one of its methods and a schedule:
500
604
 
501
605
  | Field | What |
502
606
  | --- | --- |
503
- | `plugin`, `method` | The plugin's folder name and one of its methods, called with `{}` as a tap would |
504
- | `project` | The project whose `.repogo/plugins` holds it, an absolute path or one starting `~/`; left out, `~/.repogo/plugins` |
607
+ | `plugin`, `method` | The plugin's folder name and one of the methods it wrote (not a generated or workflow one), called with `{}` as a tap would |
608
+ | `project` | The project the run gets as `ctx.root`, an absolute path or one starting `~/`; left out, the home folder. The plugin is always the environment's |
505
609
  | `schedule` | A cron: five fields (minute hour day-of-month month day-of-week), or six with seconds first (`*/5 * * * * *`); each `*`, a number, a range, a list or a step |
506
610
  | `timezone` | Defaults to the environment's |
507
611
  | `enabled` | `false` keeps the line without running it |
@@ -520,15 +624,15 @@ isn't valid JSON pauses every line until it's fixed. Each line also shows
520
624
  has the same for a plugin's own, and a query reading them re-runs around each run.
521
625
 
522
626
  Schedule something only when the user asks for it. A cron's `ctx.root` is its
523
- project, or the home folder for a plugin in `~/.repogo/plugins`. Plugins in one
524
- folder share a thread: code that blocks it holds up every other cron there, and
525
- the host's log names the crons that were running when it happened.
627
+ line's project, or the home folder without one. Plugins share a thread: code
628
+ that blocks it holds up every other cron, and the host's log names the crons
629
+ that were running when it happened.
526
630
 
527
631
  ## Logging
528
632
 
529
633
  Write `console.log` (or `warn`, `error`, `debug`); nothing else is needed. The
530
634
  host keeps every line with the plugin, the method and the call that wrote it,
531
- who called (`card`, `agent`, `cron`), and the chat for an agent's call, plus
635
+ who called (`card`, `agent`, `cron`, `code`, or `http` and `device` for HTTP), and the chat for an agent's call, plus
532
636
  the arguments as JSON. An error a method throws, or one nothing caught, is
533
637
  kept too, with its stack. Read them with the agent's `plugins_logs` tool, or on
534
638
  a card with `<Data method="repogo:plugins.logs">`, which reloads as lines land:
@@ -545,18 +649,18 @@ Every method, handler and cron gets `ctx`:
545
649
  | Field | What |
546
650
  | --- | --- |
547
651
  | `plugin` | The plugin's name |
548
- | `caller` | Who started the call: `card`, `agent`, `cron`, or `code` |
652
+ | `caller` | Who started the call: `card`, `agent`, `cron`, `code`, or a request at the public (`http`) or device (`device`) address |
549
653
  | `root` | The calling project's checkout |
550
654
  | `collections`, `kv`, `streams`, `buckets` | The plugin's data |
551
655
  | `call(method, input)` | Runs a method of a plugin in the same folder; `"open"` is this plugin's own |
552
656
  | `plugins()` | The plugins in the same folder, with their methods |
553
- | `host(method, params)` | Runs one of the host's methods that agents and cards may call (`git.status`, `chats.list`, `actions.run`, …); others fail with `FORBIDDEN` |
657
+ | `host(method, params)` | Runs one of the host's methods that agents and cards may call (`git.status`, `chats.list`, `actions.run`, …); others fail with `FORBIDDEN`, and one that doesn't exist with `INTERNAL` |
554
658
  | `mcp(server, tool, input)` | Runs a tool of an MCP server the user connected in RepoGo and switched on for the calling project; see below |
555
659
  | `notify({ title, body, page?, url?, collapse? })` | Alerts every paired phone; a tap opens `url` (an `https` or `itms-services` link) if set, else `page`, and alerts sharing `collapse` replace each other |
556
660
  | `activity({ id, title, body?, page?, symbol?, tint?, label?, compactLabel?, progress?, timer?, status? })` | Shows or updates a Live Activity on every paired phone; see below |
557
661
  | `agent(prompt, { agent, project?, title?, timeout? })` | Starts a chat with `prompt` and waits for the agent's whole answer: `{ chatId, text }`; see below |
558
662
  | `emit(event, payload)` | Sends `<plugin>.<event>` to handlers |
559
- | `signal` | Aborts when a stream's card goes |
663
+ | `signal` | Aborts when a stream's card goes, or when an HTTP caller leaves before a read or write answers |
560
664
  | `log(...)` | The same as `console.log` |
561
665
 
562
666
  A query doesn't re-run when what a `ctx.host` method read changes.
@@ -593,7 +697,7 @@ ends it with that state, left up for 15 minutes:
593
697
  ```ts
594
698
  deploy: method(z.object({ project: z.string() }), async ({ project }, ctx) => {
595
699
  const steps = ["Build", "Test", "Deploy", "Verify"];
596
- const page = `.repogo/tabs/deploys.ui?project=${project}`;
700
+ const page = `~/.repogo/plugins/deploys/pages/index.ui?project=${project}`;
597
701
  const show = (current: number, body: string) =>
598
702
  ctx.activity({ id: `deploy:${project}`, title: `Deploying ${project}`, body, page,
599
703
  symbol: "shippingbox", progress: { kind: "steps", steps, current } });
@@ -633,11 +737,11 @@ or CLI of its own. It answers the tool's structured content, else its text
633
737
  customers: method(z.object({}), async (_, ctx) => {
634
738
  const list = await ctx.mcp("stripe", "stripe_api_read", { stripe_api_operation_id: "GetCustomers", /* … */ });
635
739
  return list.data.map((c) => ({ id: c.id, email: c.email }));
636
- }, { kind: "query" }),
740
+ }, { effect: "read" }),
637
741
  ```
638
742
 
639
- A query may run only the server's read-only tools, as a card's `Data` may. A
640
- tool marked destructive runs only from a method with a `confirm`, or one
743
+ A read may run only the server's read-only tools, as a card's `Data` may. A
744
+ tool marked destructive runs only from a method with an `approve`, or one
641
745
  called from it with `ctx.call`: the user's answer to that question is its
642
746
  yes. A refusal throws `FORBIDDEN`; a tool's own error throws `INTERNAL` with
643
747
  its message. Only servers switched on for `ctx.root` answer, so a cron of an
@@ -661,23 +765,25 @@ on: {
661
765
  | Bucket | `<name>.created`, `<name>.changed` (the file's bytes), `<name>.metaChanged`, `<name>.deleted` (the file) |
662
766
  | Any method | `<method>` (`{ input, result }`) |
663
767
 
664
- Changes to a bucket's folder made by hand emit too, once the host sees them.
768
+ Changes to a bucket's folder made by hand emit too, once the host sees them;
769
+ streams emit none. A plugin's `on` hears only its own events, and runs inside
770
+ the call that emitted them, so a slow handler delays that call's answer.
665
771
  Events run in memory: none is retried, and a handler that throws is logged.
666
772
 
667
773
  ## How cards and agents reach a plugin
668
774
 
669
775
  **Agents** use two tools. `plugins` lists every plugin with a line per method
670
- (its kind, whether it asks first, and its input's fields, optional ones ending
776
+ (its effect, whether it asks first, and its input's fields, optional ones ending
671
777
  in `?`), and every line of `~/.repogo/crons.json` with how it's going.
672
778
  `plugins` with `plugin` set answers that plugin in full: each method's input
673
- and output JSON Schema and confirm, its `storage` (each bucket's and stream's
779
+ and output JSON Schema, `approve` and `approveUpTo`, its `storage` (each bucket's and stream's
674
780
  folder), the pages that show its methods, and the cron lines naming it.
675
781
  `plugin_call` runs one. Inspect a method's schema before naming it in a card.
676
- Verify query fields with a read-only call. Do not execute mutations just to
782
+ Verify a read's fields with a read-only call. Do not execute writes just to
677
783
  check a card; run them only when the user's task calls for that action.
678
784
 
679
785
  **Cards** name methods as `plugin:<plugin>.<method>`. A `Data` element runs a
680
- query, or follows a stream, and redraws when its data changes. `Button`,
786
+ read, or follows a stream, and redraws when its data changes. `Button`,
681
787
  `Item` and `Swipe` actions call methods; on iOS, `Toggle` can call one on a
682
788
  user flip. `Upload` calls a plugin method with staged files. Params must match
683
789
  the discovered input schema. These fragments assume `q` and `note` are supplied
@@ -701,10 +807,10 @@ by the enclosing card:
701
807
  ### Saving from a switch
702
808
 
703
809
  Prefer SDK storage for plugin-owned data. A storage declaration generates
704
- methods; ordinary saves do not need a custom mutation. For example:
810
+ methods; ordinary saves do not need a custom write method. For example:
705
811
 
706
812
  ```ts
707
- // .repogo/plugins/preferences/app.ts
813
+ // ~/.repogo/plugins/preferences/app.ts
708
814
  import { definePlugin, z } from "@repogo/plugins";
709
815
 
710
816
  export default definePlugin({
@@ -738,19 +844,47 @@ Inside plugin code, `ctx.kv.flags.set("alerts", true)` saves the same value.
738
844
  The generated `preferences.flags.get({ key: "alerts" })` returns an entry with
739
845
  `value` and timestamps, while `ctx.kv.flags.get("alerts")` returns the value alone.
740
846
 
741
- ## Errors and confirmations
847
+ ## Errors and approvals
848
+
849
+ Throw `new MethodError(code, message, fieldErrors?)` for a failure the caller
850
+ should read: `VALIDATION`, `NOT_FOUND`, `FORBIDDEN` or `INTERNAL`.
851
+ `fieldErrors` maps a JSON pointer (`"/minutes"`) to its message, and reaches an
852
+ HTTP caller as `fields`. Input that fails its schema answers `VALIDATION` with
853
+ a message per field. Anything else thrown is
854
+ `INTERNAL` with its message. An HTTP caller gets only the first three in their
855
+ words; `INTERNAL` reaches it as `Internal server error`
856
+ ([Serving over HTTP](#serving-over-http)).
857
+
858
+ A method's `approve` is the question asked before it runs. `{{field}}` in it
859
+ is filled from the call's input, so it can say what it acts on:
860
+ `approve: "Restart {{machine}}?"`. A `destructive` method must have one.
861
+
862
+ - **A card's tap** always asks it first.
863
+ - **An agent's `plugin_call`** asks it in the chat, as a tool approval, and runs
864
+ the method only on the user's yes. `approveUpTo` names the least strict chat
865
+ mode that still asks; the method asks in that mode and every stricter one,
866
+ and runs unasked in the rest. The modes, strictest first, are
867
+ `approval-required`, `auto-accept-edits` and `full-access`; unset is
868
+ `full-access`, so it asks in every mode. A chat whose mode the host can't
869
+ tell asks.
870
+ - **Plan mode:** an agent's call to any method that isn't a `read` is refused,
871
+ with or without an `approve`.
872
+ - **Code:** `ctx.call` from plugin code skips it, and an unattended workflow
873
+ or cron refuses methods that ask.
742
874
 
743
- Throw `new MethodError(code, message)` for a failure the caller should read:
744
- `VALIDATION`, `NOT_FOUND`, `FORBIDDEN` or `INTERNAL`. Input that fails its
745
- schema answers `VALIDATION` with a message per field. Anything else thrown is
746
- `INTERNAL` with its message.
875
+ ```ts
876
+ upload: method(Build, run, {
877
+ effect: "destructive",
878
+ approve: "Upload build {{buildNumber}}?", // asks in every mode
879
+ }),
880
+ rename: method(Rename, run, {
881
+ approve: "Rename {{name}}?",
882
+ approveUpTo: "auto-accept-edits", // full access runs it unasked
883
+ }),
884
+ ```
747
885
 
748
- A method's `confirm` is asked before a card's tap runs it. An agent's
749
- `plugin_call` gets it back as a `CONFIRM` error until it calls again with
750
- `confirmed: true`, once the user agrees. Ordinary `ctx.call` from plugin code skips
751
- it; an unattended workflow refuses confirmation-gated methods. Give one to anything that can't be undone. `{{field}}` in the question is
752
- filled from the call's input, so it can say what it acts on:
753
- `confirm: "Restart {{machine}}?"`.
886
+ In full access an agent can run commands without asking, so there the
887
+ question catches a careless call rather than stopping a determined one.
754
888
 
755
889
  ## Testing
756
890
 
@@ -762,12 +896,16 @@ user's MCP servers:
762
896
  import { testRuntime } from "@repogo/plugins/testing";
763
897
  import notes from "./app";
764
898
 
765
- const app = await testRuntime({ notes });
766
- await app.call("notes.add", { text: "hi" });
899
+ const app = await testRuntime({ notes }); // { root?, mcp?, host? } as a second argument
900
+ await app.call("notes.notes.create", { text: "hi" });
767
901
  await app.call("notes.clear", {}, { confirmed: false }); // throws CONFIRM, as an unanswered tap
768
- app.setCrons([{ plugin: "notes", method: "tidy", schedule: "* * * * *" }]); // as a crons.json line would
902
+ app.setCrons([{ plugin: "notes", method: "open", schedule: "* * * * *" }]); // a crons.json line, with root for project
769
903
  await app.tickCrons(Date.now() + 60_000); // runs whatever is due by then
770
904
  app.notices; // every ctx.notify so far
771
905
  app.logs; // every console line so far, with its method and caller
906
+ app.manifest(); // the plugins and methods, as the host lists them
772
907
  await app.close();
773
908
  ```
909
+
910
+ `call` takes `{ confirmed?, caller?, root?, chat?, files? }`; leaving
911
+ `confirmed` out runs a `confirm` method without asking, as `ctx.call` does.
package/dist/context.d.ts CHANGED
@@ -2,13 +2,13 @@ import type { Bucket } from "./buckets";
2
2
  import type { Collection } from "./collections";
3
3
  import type { Kv } from "./kv";
4
4
  import type { Stream } from "./streams";
5
- import type { HTTPAccess, MethodKind } from "./define";
5
+ import type { Effect, HTTPAddresses, PermissionMode } from "./define";
6
6
  import type { Duration } from "./duration";
7
7
  /** An alert for the user's phones: what it says, and the page a tap opens. */
8
8
  export type Notice = {
9
9
  title: string;
10
10
  body: string;
11
- /** A page target, `.repogo/tabs/vercel.md?project=a`; a tap opens it. */
11
+ /** A page target, `~/.repogo/plugins/vercel/pages/index.ui?project=a`; a tap opens it. */
12
12
  page?: string;
13
13
  /** An `https` or `itms-services` link a tap opens instead of the page, such as a build's install. */
14
14
  url?: string;
@@ -53,7 +53,7 @@ export type Activity = {
53
53
  title: string;
54
54
  /** At most 120 characters, as a notice's body. */
55
55
  body?: string;
56
- /** A page target of the calling project, `.repogo/tabs/deploys.ui?project=web`; a tap opens it. */
56
+ /** A page target, `~/.repogo/plugins/deploys/pages/index.ui?project=web`; a tap opens it. */
57
57
  page?: string;
58
58
  /** An SF Symbol name, `shippingbox`; `circle.dotted`, or a check or a cross once ended, unless said. */
59
59
  symbol?: string;
@@ -74,12 +74,16 @@ export type Activity = {
74
74
  export type ManifestMethod = {
75
75
  /** `<plugin>.<method>`, the name `ctx.call` takes. */
76
76
  name: string;
77
- kind: MethodKind;
77
+ effect: Effect;
78
+ /** A read that sends items as they happen. */
79
+ stream: boolean;
78
80
  description: string | null;
79
81
  /** What a card asks before running it; null runs on tap. */
80
- confirm: string | null;
81
- /** Who may call it at the environment's own address; null, no one. */
82
- http: HTTPAccess | null;
82
+ approve: string | null;
83
+ /** The least strict chat mode an agent's call still asks in; null with no approve. */
84
+ approveUpTo: PermissionMode | null;
85
+ /** Who may call it over HTTP, at each address; null, no one anywhere. */
86
+ http: HTTPAddresses | null;
83
87
  /** JSON Schema of the input, and of the output when the method declares one. */
84
88
  input: unknown;
85
89
  output: unknown;
@@ -113,13 +117,15 @@ export type ManifestStorage = {
113
117
  };
114
118
  /**
115
119
  * One schedule the host gives a Worker, from a line of `~/.repogo/crons.json`: call
116
- * `<plugin>.<method>` with `{}` when `schedule` comes due in `timezone` (the environment's).
120
+ * `<plugin>.<method>` with `{}` when `schedule` comes due in `timezone` (the environment's),
121
+ * with `root` (the line's project) as `ctx.root`, else the Worker's own.
117
122
  */
118
123
  export type CronEntry = {
119
124
  plugin: string;
120
125
  method: string;
121
126
  schedule: string;
122
127
  timezone?: string;
128
+ root?: string;
123
129
  };
124
130
  /** A schedule and how it's going; `error` says why it couldn't be set, and then nothing runs. */
125
131
  export type CronStatus = ManifestCron & {
@@ -135,8 +141,8 @@ export type Manifest = {
135
141
  storage: ManifestStorage;
136
142
  }[];
137
143
  };
138
- /** Who started a call: a tap or a card's query, an agent's `plugin_call`, a cron, or plugin code with none of those behind it. */
139
- export type Caller = "card" | "agent" | "cron" | "code" | "http";
144
+ /** Who started a call: a tap or a card's query, an agent's `plugin_call`, a cron, plugin code with none of those behind it, or a request at the public (`http`) or device address. */
145
+ export type Caller = "card" | "agent" | "cron" | "code" | "http" | "device";
140
146
  /** How `ctx.agent` runs a chat: which agent, in which project, and how long to wait for its answer. */
141
147
  export type AgentOptions = {
142
148
  /** `claude` or `codex`, as `agents.list` names them. */
@@ -173,15 +179,15 @@ export type Context = {
173
179
  * Runs a tool of an MCP server the user switched on for the calling project:
174
180
  * `ctx.mcp("stripe", "stripe_api_read", { … })`, answering the tool's result.
175
181
  * A query may run only read-only tools; a destructive tool runs only from a
176
- * method with a `confirm`. Its answer doesn't re-run a query.
182
+ * method with an `approve`. Its answer doesn't re-run a query.
177
183
  */
178
184
  mcp: (server: string, tool: string, input?: unknown) => Promise<unknown>;
179
185
  /**
180
- * The plugins beside this one, in the same folder (the project's, or the
181
- * environment's), with their methods: what `call` can reach.
186
+ * The plugins beside this one in the environment's plugins folder, with
187
+ * their methods: what `call` can reach.
182
188
  */
183
189
  plugins: () => Manifest["plugins"];
184
- /** Aborts when a stream's last watcher leaves; a query or mutation's never does. */
190
+ /** Aborts when a stream's last watcher leaves, or when an HTTP caller of a read or write goes away first. */
185
191
  signal: AbortSignal;
186
192
  /** Sends an alert to every phone paired with this environment. */
187
193
  notify: (notice: Notice) => void;