@joe-broadhead/opencode-gws 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +15 -3
- package/dist/index.js +578 -183
- package/package.json +111 -5
- package/skills/opencode-gws/SKILL.md +24 -2
- package/skills/opencode-gws-admin/SKILL.md +2 -2
- package/skills/opencode-gws-recipes/SKILL.md +20 -0
- package/views/gmail-thread.html +193 -0
- package/views/jelly-view.d.ts +73 -0
- package/views/jelly-view.js +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joe-broadhead/opencode-gws",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.1",
|
|
4
4
|
"description": "Unofficial OpenCode plugin for Google Workspace (gws CLI)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"dist",
|
|
29
29
|
"skills",
|
|
30
30
|
"manifest/generated.json",
|
|
31
|
+
"views",
|
|
31
32
|
"README.md",
|
|
32
33
|
"LICENSE",
|
|
33
34
|
"NOTICE",
|
|
@@ -49,10 +50,10 @@
|
|
|
49
50
|
"docs:coverage": "node scripts/generate-docs.mjs"
|
|
50
51
|
},
|
|
51
52
|
"optionalDependencies": {
|
|
52
|
-
"@joe-broadhead/opencode-gws-bin-darwin-arm64": "0.0.
|
|
53
|
-
"@joe-broadhead/opencode-gws-bin-darwin-x64": "0.0.
|
|
54
|
-
"@joe-broadhead/opencode-gws-bin-linux-arm64-gnu": "0.0.
|
|
55
|
-
"@joe-broadhead/opencode-gws-bin-linux-x64-gnu": "0.0.
|
|
53
|
+
"@joe-broadhead/opencode-gws-bin-darwin-arm64": "0.0.1",
|
|
54
|
+
"@joe-broadhead/opencode-gws-bin-darwin-x64": "0.0.1",
|
|
55
|
+
"@joe-broadhead/opencode-gws-bin-linux-arm64-gnu": "0.0.1",
|
|
56
|
+
"@joe-broadhead/opencode-gws-bin-linux-x64-gnu": "0.0.1"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
59
|
"@babel/core": "7.29.7",
|
|
@@ -71,5 +72,110 @@
|
|
|
71
72
|
"typescript": "5.9.3",
|
|
72
73
|
"vitest": "5.0.5",
|
|
73
74
|
"zod": "4.6.7"
|
|
75
|
+
},
|
|
76
|
+
"jelly": {
|
|
77
|
+
"title": "Google Workspace",
|
|
78
|
+
"tools": [
|
|
79
|
+
{
|
|
80
|
+
"name": "gws.status",
|
|
81
|
+
"permission": "gws.status",
|
|
82
|
+
"effect": "allow",
|
|
83
|
+
"readOnly": true
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "gws.catalog",
|
|
87
|
+
"permission": "gws.catalog",
|
|
88
|
+
"effect": "allow",
|
|
89
|
+
"readOnly": true
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"name": "gws.schema",
|
|
93
|
+
"permission": "gws.schema",
|
|
94
|
+
"effect": "allow",
|
|
95
|
+
"readOnly": true
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "gws.read",
|
|
99
|
+
"permission": "gws.read",
|
|
100
|
+
"effect": "allow",
|
|
101
|
+
"readOnly": true
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"name": "gws.plan",
|
|
105
|
+
"permission": "gws.plan",
|
|
106
|
+
"effect": "allow",
|
|
107
|
+
"readOnly": true
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "gws.write",
|
|
111
|
+
"permission": "gws.write",
|
|
112
|
+
"effect": "ask",
|
|
113
|
+
"readOnly": false
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "gws.export",
|
|
117
|
+
"permission": "gws.export",
|
|
118
|
+
"effect": "ask",
|
|
119
|
+
"readOnly": false
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "gws.send",
|
|
123
|
+
"permission": "gws.send",
|
|
124
|
+
"effect": "ask",
|
|
125
|
+
"readOnly": false
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"name": "gws.share",
|
|
129
|
+
"permission": "gws.share",
|
|
130
|
+
"effect": "ask",
|
|
131
|
+
"readOnly": false
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"name": "gws.delete",
|
|
135
|
+
"permission": "gws.delete",
|
|
136
|
+
"effect": "ask",
|
|
137
|
+
"readOnly": false
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"name": "gws.admin",
|
|
141
|
+
"permission": "gws.admin",
|
|
142
|
+
"effect": "ask",
|
|
143
|
+
"readOnly": false
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
"skills": [
|
|
147
|
+
"opencode-gws",
|
|
148
|
+
"opencode-gws-recipes",
|
|
149
|
+
"opencode-gws-admin"
|
|
150
|
+
],
|
|
151
|
+
"rpc": [],
|
|
152
|
+
"views": [
|
|
153
|
+
{
|
|
154
|
+
"v": 1,
|
|
155
|
+
"id": "gmail-thread",
|
|
156
|
+
"place": "tool-result",
|
|
157
|
+
"tools": [
|
|
158
|
+
"gws_read"
|
|
159
|
+
],
|
|
160
|
+
"title": "Gmail thread",
|
|
161
|
+
"page": "views/gmail-thread.html",
|
|
162
|
+
"grants": [
|
|
163
|
+
"data"
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
"signIns": [
|
|
168
|
+
{
|
|
169
|
+
"id": "google",
|
|
170
|
+
"label": "Google Workspace",
|
|
171
|
+
"integration": "gws-*"
|
|
172
|
+
}
|
|
173
|
+
],
|
|
174
|
+
"contributes": {
|
|
175
|
+
"homeFeeds": [],
|
|
176
|
+
"homeWidgets": [],
|
|
177
|
+
"workKinds": [],
|
|
178
|
+
"settings": []
|
|
179
|
+
}
|
|
74
180
|
}
|
|
75
181
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: opencode-gws
|
|
3
|
-
description: Use when working with Google Workspace accounts, reads, plans, commits, approvals, or
|
|
3
|
+
description: Use when working with Google Workspace accounts, reads, plans, commits, approvals, errors, or scripts that call these tools without a model.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Google Workspace
|
|
@@ -13,7 +13,7 @@ Call `gws_status` before the first Workspace call in a session. It lists account
|
|
|
13
13
|
|
|
14
14
|
Omit `account` when `gws_status` shows one account. Pass the alias when it shows more than one. A missing alias is `ACCOUNT_REQUIRED`. An unknown alias is `ACCOUNT_UNKNOWN`.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
An account signs in Read-only or Read and write; `gws_status` shows which. The `default` account defaults to Read and write, which covers every method a Google sign-in can run. An operation whose scopes were not granted fails with `AUTH_SCOPE_MISSING` before anything runs: ask the user to sign in again, approve every scope Google lists, and choose Read and write if the error says so. Do not invent a scope. A method that `gws_catalog` or `gws_schema` marks `appOnly` needs a Chat app's or Classroom add-on's own credentials; no sign-in can run it, so use another method.
|
|
17
17
|
|
|
18
18
|
Not signed in: `AUTH_REQUIRED`. The next step is to run `/connect` and choose Google Workspace, or `opencode auth login gws-<alias>`.
|
|
19
19
|
|
|
@@ -88,6 +88,25 @@ Add this block first in **that agent's own** `permissions`, then any rules the u
|
|
|
88
88
|
|
|
89
89
|
Do not put that block in the top-level `permissions`. Top-level rules apply after every agent's base rules and would loosen Explore.
|
|
90
90
|
|
|
91
|
+
## Scripts
|
|
92
|
+
|
|
93
|
+
When the user wants a job that runs without you, such as a cron job, a shell script, or another program, it can call these tools through the OpenCode server. The RPC `google-workspace.gws.tools` has one method per tool, with the same input and output: `status`, `catalog`, `schema`, `read`, and `plan` are `gws_status`, `gws_catalog`, `gws_schema`, `gws_read`, and `gws_plan`. `describe` lists the methods with their input and output as JSON Schema. The commit tools are not methods: a script can plan, but only a commit tool, approved in the plan's session, applies the change.
|
|
94
|
+
|
|
95
|
+
A script runs as a session that names an agent, under that agent's permission rules and the session's own. Have the script create its session once, with `opencode api post /api/session` and a body such as `{"title":"mail digest","agent":"build"}`, then keep the returned id. Session rules can narrow the script further, for example a deny on `gws.*` followed by an allow on `gws.read` for `personal:gmail.users.messages.list`.
|
|
96
|
+
|
|
97
|
+
Each call is `opencode api post /api/rpc/google-workspace.gws.tools/<method>` with this body:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"input": {
|
|
102
|
+
"sessionID": "SESSION_ID",
|
|
103
|
+
"input": { "operation": "gmail.users.messages.list", "params": { "userId": "me", "q": "is:unread" }, "fields": "messages(id),nextPageToken" }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The result is `{ "output": ... }`, the tool's output, with the same untrusted Workspace content. A refusal is the error `failed`, with `data.code` and `data.next` from the table below. No one answers a prompt for a script, so an ask on its call is refused with `PERMISSION_DENIED`: the script needs an allow rule, or no rule, for what it does. Reads have no ask rule.
|
|
109
|
+
|
|
91
110
|
## Errors
|
|
92
111
|
|
|
93
112
|
An error is `CODE: What happened. Next step: What to do.` Do the next step once. Do not loop.
|
|
@@ -102,3 +121,6 @@ An error is `CODE: What happened. Next step: What to do.` Do the next step once.
|
|
|
102
121
|
| `POLICY_DENIED` | Stop. Tell the user. Do not rephrase the call to get around the deny. |
|
|
103
122
|
| `GWS_VALIDATION_ERROR` | Fix the request. `gws_schema` shows the parameters. List and search need `fields`. |
|
|
104
123
|
| `CANCELLED` | Do not retry unless the user asks. A commit that was already running may have been applied. Check with `gws_read` before planning again. |
|
|
124
|
+
| `SESSION_UNKNOWN` | A script named a session that does not exist. Have it create a session and keep that id. |
|
|
125
|
+
| `SESSION_AGENT_REQUIRED` | The script's session names no agent. Create the session with `agent`. |
|
|
126
|
+
| `PERMISSION_DENIED` | The agent's or the session's rules deny the script's call, or would ask. Tell the user. An allow rule for that action is the fix; do not route around it. |
|
|
@@ -14,7 +14,7 @@ Search with `gws_catalog` and check `gws_schema` before you call. If `executable
|
|
|
14
14
|
|
|
15
15
|
An operation whose category is `admin` needs all of these before `gws_plan` will accept it:
|
|
16
16
|
|
|
17
|
-
1. `admin.enabled` is true.
|
|
17
|
+
1. `admin.enabled` is true, which is the default.
|
|
18
18
|
2. The service is in `admin.allowedServices` (the default list is the services that have an admin-class entry).
|
|
19
19
|
3. The plan includes a non-empty `reason` when `admin.requireReason` is true, which is the default.
|
|
20
20
|
|
|
@@ -76,7 +76,7 @@ Most subscription methods never run. A status read can:
|
|
|
76
76
|
|
|
77
77
|
## Model Armor
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
Read and write requests `https://www.googleapis.com/auth/cloud-platform` for Model Armor; Read-only requests nothing for it. The Model Armor helpers cannot run. Do not invent a call. Tell the user, and use `gws_catalog` if they still want a Discovery method.
|
|
80
80
|
|
|
81
81
|
## Workflow
|
|
82
82
|
|
|
@@ -23,6 +23,26 @@ Unread inbox, read:
|
|
|
23
23
|
}
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Mail between two times, read. `after:` and `before:` take Unix seconds; `newer_than:` and `older_than:` take only days, months, or years:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"operation": "gmail.users.messages.list",
|
|
31
|
+
"params": { "userId": "me", "q": "after:1767225600 before:1767312000", "maxResults": 100 },
|
|
32
|
+
"fields": "messages(id,threadId),nextPageToken"
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Sender, subject, and date of one message, read, without the body:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"operation": "gmail.users.messages.get",
|
|
41
|
+
"params": { "userId": "me", "id": "MESSAGE_ID", "format": "metadata", "metadataHeaders": ["From", "Subject"] },
|
|
42
|
+
"fields": "id,threadId,snippet,internalDate,payload/headers"
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
26
46
|
One message, read. Prefer the helper when you want the text, not the raw payload:
|
|
27
47
|
|
|
28
48
|
```json
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Gmail thread</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { color: var(--jelly-text, #222); background: var(--jelly-surface, #fff); font: 14px/1.45 var(--jelly-font, system-ui, sans-serif); }
|
|
8
|
+
body { margin: 0; padding: 12px 14px; }
|
|
9
|
+
h1 { font-size: 15px; font-weight: 600; margin: 0 0 10px; }
|
|
10
|
+
ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
|
11
|
+
li { border: 1px solid var(--jelly-border, #ddd); border-radius: var(--jelly-radius, 8px); background: var(--jelly-raised, transparent); padding: 10px 12px; }
|
|
12
|
+
.head { display: flex; flex-wrap: wrap; gap: 4px 12px; justify-content: space-between; margin-bottom: 6px; }
|
|
13
|
+
.from { font-weight: 600; overflow-wrap: anywhere; }
|
|
14
|
+
.at, .note { color: var(--jelly-muted, #666); font-size: 12px; }
|
|
15
|
+
.body { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; }
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<main id="thread" aria-live="polite"></main>
|
|
20
|
+
<script>
|
|
21
|
+
// jelly-view.js: a client of Jelly's view bridge. Paste it into the page's own <script>:
|
|
22
|
+
// a sealed page loads no script by URL and is given no global. Call jellyView() as the
|
|
23
|
+
// script starts; it resolves with the client when the app says hello.
|
|
24
|
+
function jellyView() {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
addEventListener("message", function hello(event) {
|
|
27
|
+
const said = event.data;
|
|
28
|
+
if (event.source !== parent || said?.v !== 1 || said.kind !== "hello" || !event.ports[0])
|
|
29
|
+
return;
|
|
30
|
+
removeEventListener("message", hello);
|
|
31
|
+
const port = event.ports[0];
|
|
32
|
+
const listeners = new Set();
|
|
33
|
+
const latest = new Map();
|
|
34
|
+
const queue = [];
|
|
35
|
+
let sequence = 0;
|
|
36
|
+
let current;
|
|
37
|
+
const theme = (tokens) => {
|
|
38
|
+
const style = document.documentElement.style;
|
|
39
|
+
for (const [name, value] of Object.entries(tokens))
|
|
40
|
+
if (name === "scheme") style.colorScheme = value;
|
|
41
|
+
else style.setProperty(`--jelly-${name}`, value);
|
|
42
|
+
};
|
|
43
|
+
// The app answers one request at a time, so requests go in turn.
|
|
44
|
+
const next = () => {
|
|
45
|
+
if (current !== undefined || queue.length === 0) return;
|
|
46
|
+
current = queue.shift();
|
|
47
|
+
current.timer = setTimeout(
|
|
48
|
+
() => settle({ ok: false, error: "No answer from the app" }),
|
|
49
|
+
15000,
|
|
50
|
+
);
|
|
51
|
+
port.postMessage(current.message);
|
|
52
|
+
};
|
|
53
|
+
const settle = (answer) => {
|
|
54
|
+
const done = current;
|
|
55
|
+
current = undefined;
|
|
56
|
+
clearTimeout(done.timer);
|
|
57
|
+
if (answer.ok) done.resolve(answer.value);
|
|
58
|
+
else done.reject(new Error(answer.error));
|
|
59
|
+
next();
|
|
60
|
+
};
|
|
61
|
+
port.onmessage = ({ data }) => {
|
|
62
|
+
if (data?.v !== 1) return;
|
|
63
|
+
if (typeof data.kind !== "string") {
|
|
64
|
+
if (current !== undefined && data.re === current.message.id) settle(data);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// A call waiting for the person is answered by `until`, not in 15 s.
|
|
68
|
+
if (data.kind === "rpc.waiting" && data.re === current?.message.id) {
|
|
69
|
+
clearTimeout(current.timer);
|
|
70
|
+
current.timer = setTimeout(
|
|
71
|
+
() => settle({ ok: false, error: "No answer from the app" }),
|
|
72
|
+
Math.max(0, Date.parse(data.until) - Date.now()) + 15000,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (data.kind === "theme.changed") theme(data.theme);
|
|
76
|
+
// A wait is news only while it lasts, so a later listener is not told of it.
|
|
77
|
+
if (data.kind !== "rpc.waiting") latest.set(data.kind, data);
|
|
78
|
+
for (const listener of listeners) listener(data);
|
|
79
|
+
};
|
|
80
|
+
port.start();
|
|
81
|
+
theme(said.theme);
|
|
82
|
+
const request = (op, fields = {}) =>
|
|
83
|
+
new Promise((resolve, reject) => {
|
|
84
|
+
queue.push({ resolve, reject, message: { ...fields, v: 1, id: String(++sequence), op } });
|
|
85
|
+
next();
|
|
86
|
+
});
|
|
87
|
+
resolve({
|
|
88
|
+
hello: said,
|
|
89
|
+
// A listener first hears the latest push of each kind that came before it.
|
|
90
|
+
on(listener) {
|
|
91
|
+
for (const push of latest.values()) listener(push);
|
|
92
|
+
listeners.add(listener);
|
|
93
|
+
return () => listeners.delete(listener);
|
|
94
|
+
},
|
|
95
|
+
request,
|
|
96
|
+
data: () => request("data.get"),
|
|
97
|
+
state: () => request("state.get"),
|
|
98
|
+
setState: (revision, data) => request("state.set", { revision, data }),
|
|
99
|
+
call: (method, input = {}) => request("rpc.call", { method, input }),
|
|
100
|
+
openAgent: (prefill) => request("open-agent", prefill === undefined ? {} : { prefill }),
|
|
101
|
+
resize: (height) =>
|
|
102
|
+
request("resize", { height: Math.min(1200, Math.max(1, Math.ceil(height))) }),
|
|
103
|
+
decline: () => request("decline"),
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* gws_read's answer for gmail.users.threads.get, as a subject and its messages.
|
|
111
|
+
* Null for any other operation, so the view declines and the call keeps its
|
|
112
|
+
* ordinary look. Bodies come from the text/plain part when the read asked for
|
|
113
|
+
* it, else from Gmail's snippet; nothing is fetched and no HTML is rendered.
|
|
114
|
+
*/
|
|
115
|
+
function threadOf(output) {
|
|
116
|
+
if (!output || output.operation !== "gmail.users.threads.get") return null;
|
|
117
|
+
const data = output.data || {};
|
|
118
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
119
|
+
if (messages.length === 0) return null;
|
|
120
|
+
const header = (message, name) => {
|
|
121
|
+
const headers = (message.payload && message.payload.headers) || [];
|
|
122
|
+
const found = headers.find((entry) => String(entry.name).toLowerCase() === name);
|
|
123
|
+
return found ? String(found.value) : "";
|
|
124
|
+
};
|
|
125
|
+
const plain = (part) => {
|
|
126
|
+
if (!part) return "";
|
|
127
|
+
if (part.mimeType === "text/plain" && part.body && typeof part.body.data === "string") {
|
|
128
|
+
const binary = atob(part.body.data.replace(/-/g, "+").replace(/_/g, "/"));
|
|
129
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
|
130
|
+
}
|
|
131
|
+
for (const child of part.parts || []) {
|
|
132
|
+
const text = plain(child);
|
|
133
|
+
if (text !== "") return text;
|
|
134
|
+
}
|
|
135
|
+
return "";
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
subject: header(messages[0], "subject") || "(no subject)",
|
|
139
|
+
messages: messages.map((message) => {
|
|
140
|
+
const time = Number(message.internalDate);
|
|
141
|
+
return {
|
|
142
|
+
from: header(message, "from") || "Unknown sender",
|
|
143
|
+
at: Number.isFinite(time) && time > 0 ? new Date(time).toISOString() : header(message, "date"),
|
|
144
|
+
body: (plain(message.payload) || String(message.snippet || "")).trim().slice(0, 4000),
|
|
145
|
+
};
|
|
146
|
+
}),
|
|
147
|
+
truncated: output.truncated === true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function draw(root, thread) {
|
|
152
|
+
root.replaceChildren();
|
|
153
|
+
const title = document.createElement("h1");
|
|
154
|
+
title.textContent = thread.subject;
|
|
155
|
+
const list = document.createElement("ol");
|
|
156
|
+
for (const message of thread.messages) {
|
|
157
|
+
const item = document.createElement("li");
|
|
158
|
+
const head = document.createElement("div");
|
|
159
|
+
head.className = "head";
|
|
160
|
+
const from = document.createElement("span");
|
|
161
|
+
from.className = "from";
|
|
162
|
+
from.textContent = message.from;
|
|
163
|
+
const at = document.createElement("time");
|
|
164
|
+
at.className = "at";
|
|
165
|
+
at.textContent = message.at.replace("T", " ").replace(/:\d\d\.\d+Z$/, " UTC");
|
|
166
|
+
at.dateTime = message.at;
|
|
167
|
+
const body = document.createElement("p");
|
|
168
|
+
body.className = "body";
|
|
169
|
+
body.textContent = message.body || "No text in this message.";
|
|
170
|
+
head.append(from, at);
|
|
171
|
+
item.append(head, body);
|
|
172
|
+
list.append(item);
|
|
173
|
+
}
|
|
174
|
+
root.append(title, list);
|
|
175
|
+
if (thread.truncated) {
|
|
176
|
+
const note = document.createElement("p");
|
|
177
|
+
note.className = "note";
|
|
178
|
+
note.textContent = "Gmail's answer was cut short; not every message is shown.";
|
|
179
|
+
root.append(note);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (typeof document !== "undefined" && document.getElementById("thread")) {
|
|
184
|
+
jellyView().then(async (view) => {
|
|
185
|
+
const thread = threadOf((await view.data())?.output);
|
|
186
|
+
if (thread === null) return view.decline();
|
|
187
|
+
draw(document.getElementById("thread"), thread);
|
|
188
|
+
view.resize(document.documentElement.scrollHeight);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
</script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The types of `jelly-view.js`, for a page written or checked with TypeScript:
|
|
3
|
+
* `/// <reference path="./jelly-view.d.ts" />`. They mirror the bridge envelopes
|
|
4
|
+
* in Jelly's contracts (`protocol/contracts/src/view-bridge.ts`); the page itself
|
|
5
|
+
* carries only the JavaScript.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
type JellyViewPlace = "tool-result" | "home-widget" | "settings-section";
|
|
9
|
+
type JellyViewGrant = "data" | "state" | "rpc" | "open-agent";
|
|
10
|
+
|
|
11
|
+
/** The fixed theme token set; the helper sets each as `--jelly-<name>` on the page. */
|
|
12
|
+
interface JellyViewTheme {
|
|
13
|
+
text: string;
|
|
14
|
+
muted: string;
|
|
15
|
+
surface: string;
|
|
16
|
+
raised: string;
|
|
17
|
+
border: string;
|
|
18
|
+
accent: string;
|
|
19
|
+
"on-accent": string;
|
|
20
|
+
danger: string;
|
|
21
|
+
font: string;
|
|
22
|
+
radius: string;
|
|
23
|
+
scheme: "light" | "dark";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface JellyViewHello {
|
|
27
|
+
v: 1;
|
|
28
|
+
kind: "hello";
|
|
29
|
+
/** `package` is null for a Home app an agent made. */
|
|
30
|
+
view: { package: string | null; id: string; title: string; place: JellyViewPlace };
|
|
31
|
+
grants: JellyViewGrant[];
|
|
32
|
+
/** The methods `call` may name. */
|
|
33
|
+
rpc: string[];
|
|
34
|
+
theme: JellyViewTheme;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A view's state: JSON of at most 64 KB, and the revision a write must name. */
|
|
38
|
+
interface JellyViewState {
|
|
39
|
+
revision: number;
|
|
40
|
+
data: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type JellyViewPush =
|
|
44
|
+
| { v: 1; kind: "data.changed"; data: unknown }
|
|
45
|
+
| ({ v: 1; kind: "state.changed" } & JellyViewState)
|
|
46
|
+
| { v: 1; kind: "theme.changed"; theme: JellyViewTheme }
|
|
47
|
+
| { v: 1; kind: "visibility"; visible: boolean }
|
|
48
|
+
/** The call with request id `re` waits for the person, at most until `until` (ISO time). */
|
|
49
|
+
| { v: 1; kind: "rpc.waiting"; re: string; until: string };
|
|
50
|
+
|
|
51
|
+
interface JellyView {
|
|
52
|
+
hello: JellyViewHello;
|
|
53
|
+
/** Hear pushes; returns the way to stop. */
|
|
54
|
+
on(listener: (push: JellyViewPush) => void): () => void;
|
|
55
|
+
/** Any request by its op, for one the helper has no name for. */
|
|
56
|
+
request(op: string, fields?: Record<string, unknown>): Promise<unknown>;
|
|
57
|
+
/** The place's data (the `data` grant). */
|
|
58
|
+
data(): Promise<unknown>;
|
|
59
|
+
/** The view's state (the `state` grant). */
|
|
60
|
+
state(): Promise<JellyViewState>;
|
|
61
|
+
/** Replace the state if `revision` is still the stored one; rejects on a conflict. */
|
|
62
|
+
setState(revision: number, data: unknown): Promise<JellyViewState>;
|
|
63
|
+
/** One of the methods the hello lists (the `rpc` grant). */
|
|
64
|
+
call(method: string, input?: unknown): Promise<unknown>;
|
|
65
|
+
/** Start a message for the person to send; it is never sent (the `open-agent` grant). */
|
|
66
|
+
openAgent(prefill?: string): Promise<null>;
|
|
67
|
+
/** The height the page needs, in CSS pixels, up to 1200: a hint the app may draw shorter. */
|
|
68
|
+
resize(height: number): Promise<null>;
|
|
69
|
+
/** Not mine: the app closes the frame and keeps its ordinary look. */
|
|
70
|
+
decline(): Promise<null>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
declare function jellyView(): Promise<JellyView>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// jelly-view.js: a client of Jelly's view bridge. Paste it into the page's own <script>:
|
|
2
|
+
// a sealed page loads no script by URL and is given no global. Call jellyView() as the
|
|
3
|
+
// script starts; it resolves with the client when the app says hello.
|
|
4
|
+
function jellyView() {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
addEventListener("message", function hello(event) {
|
|
7
|
+
const said = event.data;
|
|
8
|
+
if (event.source !== parent || said?.v !== 1 || said.kind !== "hello" || !event.ports[0])
|
|
9
|
+
return;
|
|
10
|
+
removeEventListener("message", hello);
|
|
11
|
+
const port = event.ports[0];
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const latest = new Map();
|
|
14
|
+
const queue = [];
|
|
15
|
+
let sequence = 0;
|
|
16
|
+
let current;
|
|
17
|
+
const theme = (tokens) => {
|
|
18
|
+
const style = document.documentElement.style;
|
|
19
|
+
for (const [name, value] of Object.entries(tokens))
|
|
20
|
+
if (name === "scheme") style.colorScheme = value;
|
|
21
|
+
else style.setProperty(`--jelly-${name}`, value);
|
|
22
|
+
};
|
|
23
|
+
// The app answers one request at a time, so requests go in turn.
|
|
24
|
+
const next = () => {
|
|
25
|
+
if (current !== undefined || queue.length === 0) return;
|
|
26
|
+
current = queue.shift();
|
|
27
|
+
current.timer = setTimeout(
|
|
28
|
+
() => settle({ ok: false, error: "No answer from the app" }),
|
|
29
|
+
15000,
|
|
30
|
+
);
|
|
31
|
+
port.postMessage(current.message);
|
|
32
|
+
};
|
|
33
|
+
const settle = (answer) => {
|
|
34
|
+
const done = current;
|
|
35
|
+
current = undefined;
|
|
36
|
+
clearTimeout(done.timer);
|
|
37
|
+
if (answer.ok) done.resolve(answer.value);
|
|
38
|
+
else done.reject(new Error(answer.error));
|
|
39
|
+
next();
|
|
40
|
+
};
|
|
41
|
+
port.onmessage = ({ data }) => {
|
|
42
|
+
if (data?.v !== 1) return;
|
|
43
|
+
if (typeof data.kind !== "string") {
|
|
44
|
+
if (current !== undefined && data.re === current.message.id) settle(data);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// A call waiting for the person is answered by `until`, not in 15 s.
|
|
48
|
+
if (data.kind === "rpc.waiting" && data.re === current?.message.id) {
|
|
49
|
+
clearTimeout(current.timer);
|
|
50
|
+
current.timer = setTimeout(
|
|
51
|
+
() => settle({ ok: false, error: "No answer from the app" }),
|
|
52
|
+
Math.max(0, Date.parse(data.until) - Date.now()) + 15000,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (data.kind === "theme.changed") theme(data.theme);
|
|
56
|
+
// A wait is news only while it lasts, so a later listener is not told of it.
|
|
57
|
+
if (data.kind !== "rpc.waiting") latest.set(data.kind, data);
|
|
58
|
+
for (const listener of listeners) listener(data);
|
|
59
|
+
};
|
|
60
|
+
port.start();
|
|
61
|
+
theme(said.theme);
|
|
62
|
+
const request = (op, fields = {}) =>
|
|
63
|
+
new Promise((resolve, reject) => {
|
|
64
|
+
queue.push({ resolve, reject, message: { ...fields, v: 1, id: String(++sequence), op } });
|
|
65
|
+
next();
|
|
66
|
+
});
|
|
67
|
+
resolve({
|
|
68
|
+
hello: said,
|
|
69
|
+
// A listener first hears the latest push of each kind that came before it.
|
|
70
|
+
on(listener) {
|
|
71
|
+
for (const push of latest.values()) listener(push);
|
|
72
|
+
listeners.add(listener);
|
|
73
|
+
return () => listeners.delete(listener);
|
|
74
|
+
},
|
|
75
|
+
request,
|
|
76
|
+
data: () => request("data.get"),
|
|
77
|
+
state: () => request("state.get"),
|
|
78
|
+
setState: (revision, data) => request("state.set", { revision, data }),
|
|
79
|
+
call: (method, input = {}) => request("rpc.call", { method, input }),
|
|
80
|
+
openAgent: (prefill) => request("open-agent", prefill === undefined ? {} : { prefill }),
|
|
81
|
+
resize: (height) =>
|
|
82
|
+
request("resize", { height: Math.min(1200, Math.max(1, Math.ceil(height))) }),
|
|
83
|
+
decline: () => request("decline"),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|