viewable-one 0.0.0-stage → 0.12.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,35 @@
1
+ /** Admission requires durable completion delivery and a free slot. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { acquireAdmission } from "./admission.mjs";
4
+ import { flushCompletions } from "./runnerSettlement.mjs";
5
+ import * as runnerApiModule from "./runnerApi.mjs";
6
+
7
+ const retryHint = (out) => (Number.isFinite(out.retryAfterMs) ? { retryAfterMs: out.retryAfterMs } : {});
8
+
9
+ export async function claimOne(root, project, settings) {
10
+ const runnerApi = settings.runnerApi ?? runnerApiModule;
11
+ const rowsOpts = { env: settings.env, fetchImpl: settings.fetchImpl };
12
+ const admission = await acquireAdmission(root);
13
+ if (!admission.ok) return { claimed: false, detail: admission.detail };
14
+ try {
15
+ // `transient` (ticket e8): the claim loop backs off on these. A saved
16
+ // completion that will not sync is almost always the server failing, and
17
+ // claiming is blocked until it lands either way.
18
+ if (!(await flushCompletions(project, settings))) return { claimed: false, transient: true, detail: "Saved completions are waiting to sync." };
19
+ // With no limit on how many run at once (the default) the count can never
20
+ // refuse, so it is not asked for: one call fewer per pass (#616).
21
+ if (Number.isFinite(settings.maxConcurrent)) {
22
+ const listed = await runnerApi.runs({ projectIds: [project.id], status: "running" }, rowsOpts);
23
+ if (!listed.ok) return { claimed: false, transient: listed.transient === true, ...retryHint(listed), detail: `Cannot verify running sessions: ${listed.error}` };
24
+ const running = (listed.runs ?? []).length;
25
+ if (running >= settings.maxConcurrent) return { claimed: false, detail: `${running} session(s) already running, and up to ${settings.maxConcurrent} run at once` };
26
+ }
27
+ const claimed = await runnerApi.claim({ projectIds: [project.id], runnerInstanceId: settings.runnerInstanceId ??= randomUUID() }, rowsOpts);
28
+ if (!claimed.ok) return { claimed: false, transient: claimed.transient === true, ...retryHint(claimed), detail: claimed.error };
29
+ if (!claimed.run) return { claimed: false, idle: true, detail: "nothing pending" };
30
+ return { claimed: true, run: claimed.run };
31
+ } finally {
32
+ await admission.release();
33
+ }
34
+ }
35
+
@@ -0,0 +1,312 @@
1
+ // THE ROWS CLIENT (brief 30, Move 2, 2026-09-11). Every `.glytch/` file the
2
+ // runner used to read or write becomes a row, reached over ONE Edge Function,
3
+ // `supabase/functions/board-runner/` - built in parallel against the same
4
+ // contract (brief 30, "Move 2 contract").
5
+ // This file is the bridge's whole half of that wire: one function per op,
6
+ // same bearer resolution as everything else in this package
7
+ // (lib/auth.mjs's hostedToken - a `glytch login` credential, or
8
+ // VIEWABLE_BOARD_TOKEN for a script), never PostgREST directly, because
9
+ // neither a `pmb_` nor a `pmo_` token is a Supabase JWT.
10
+ //
11
+ // THE FUNCTION DOES NOT EXIST YET when this file was written - another
12
+ // worker builds `supabase/functions/board-runner/` against the same
13
+ // contract, in parallel. This file is written against the CONTRACT, and its
14
+ // own tests stub the HTTP layer (`fetchImpl`) rather than talking to
15
+ // anything real, exactly the way lib/hosted.mjs's tests already do for
16
+ // board-mcp.
17
+ //
18
+ // URL RESOLUTION mirrors hostedConfig: `GLYTCH_RUNNER_URL` overrides
19
+ // everything (a local dev deploy, a staging function under a different
20
+ // path); otherwise it is `${appUrl}/functions/v1/board-runner`, derived from
21
+ // the same stored `appUrl` (or `GLYTCH_APP_URL`/the production default)
22
+ // hostedConfig already resolves for the MCP endpoint - one app origin, two
23
+ // functions under it.
24
+ //
25
+ // WHY ONE call() AND NOT ONE FETCH PER OP: every op answers the same
26
+ // envelope (`{ok:true, ...}` / `{ok:false, error}`), needs the same bearer,
27
+ // and gets the same one-retry-after-forced-refresh treatment a token aging
28
+ // out mid-run already gets in lib/hosted.mjs's callTool - a spawned run can
29
+ // sit inside this contract for up to two hours (spawn.mjs's MAX_WALL_MS), so
30
+ // an access token expiring partway through a run must not read as a hard
31
+ // failure the first time it happens.
32
+
33
+ import { DEFAULT_APP_URL, hostedConfig } from "./auth.mjs";
34
+ import { RETIRED_APP_URLS } from "./retired-address.mjs";
35
+ import { authenticatedJsonPost } from "./authenticatedHttp.mjs";
36
+ export { NOT_SIGNED_IN_HINT } from "./authenticatedHttp.mjs";
37
+
38
+ /** The hosted project's own address for `board-runner` (#813, report #795
39
+ * option a). The production app proxies `/functions/v1/` through its
40
+ * Worker; the runner calls the function directly instead, so its traffic
41
+ * never meets the Worker's rate limit. The bearer is unchanged: the function
42
+ * checks a token's resource against its own configured issuer, not against
43
+ * the host the request arrived on. */
44
+ export const HOSTED_RUNNER_URL = "https://edqgfvgbxmfvfahlofqd.supabase.co/functions/v1/board-runner";
45
+
46
+ /** Where the runner's own function lives: `GLYTCH_RUNNER_URL` wins outright
47
+ * (a local dev deploy, a staging function at a different path); the
48
+ * production app goes straight to the hosted project; any other app origin
49
+ * (a local stack) uses its own `board-runner` function, derived from the same
50
+ * `appUrl` the MCP endpoint's URL comes from. */
51
+ export function runnerApiUrlFrom(appUrl, env = process.env) {
52
+ const explicit = typeof (env.VIEWABLE_RUNNER_URL ?? env.GLYTCH_RUNNER_URL) === "string" ? (env.VIEWABLE_RUNNER_URL ?? env.GLYTCH_RUNNER_URL).trim() : "";
53
+ if (explicit !== "") return explicit;
54
+ if (appUrl === DEFAULT_APP_URL || RETIRED_APP_URLS.includes(appUrl)) return HOSTED_RUNNER_URL;
55
+ return `${appUrl}/functions/v1/board-runner`;
56
+ }
57
+
58
+ /** Turn whatever an error-shaped field carries into a string worth showing a
59
+ * person, never `[object Object]` (2026-09-21: seen all night in
60
+ * runner.log as `board-runner answered 502: [object Object]`, because a
61
+ * Supabase gateway error can put an OBJECT where this package always
62
+ * expected a string). A string passes through unchanged; an object hands
63
+ * back its own `.message` when that is a string (the shape a Supabase
64
+ * gateway error carries), else the object itself, stringified and capped at
65
+ * 400 characters like every other piece of raw response text this file
66
+ * reports. `undefined`/`null` stay `undefined`, so a caller can still fall
67
+ * through to the next candidate. */
68
+ function errorDetail(value) {
69
+ if (typeof value === "string") return value;
70
+ if (value && typeof value === "object") {
71
+ if (typeof value.message === "string") return value.message;
72
+ try {
73
+ return JSON.stringify(value).slice(0, 400);
74
+ } catch {
75
+ return String(value);
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** How long a 429 or 503 asks us to wait, in ms, from its `Retry-After`
82
+ * header (seconds or an HTTP date). undefined when absent or unreadable.
83
+ * The runner sleeps at least this long before its next call (#616). */
84
+ export function retryAfterMs(value, now = Date.now()) {
85
+ if (typeof value !== "string" || value.trim() === "") return undefined;
86
+ const seconds = Number(value.trim());
87
+ if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : undefined;
88
+ const at = Date.parse(value);
89
+ return Number.isFinite(at) ? Math.max(0, at - now) : undefined;
90
+ }
91
+
92
+ /** Cloudflare's Ray ID for a failed call: the `cf-ray` header, or the
93
+ * "Ray ID: ..." line on its own HTML error page. It is what Cloudflare asks
94
+ * for when a rate limit or block needs looking into (#616). */
95
+ export function cloudflareRayId(headers, text) {
96
+ const header = headers?.get?.("cf-ray");
97
+ if (typeof header === "string" && header.trim() !== "") return header.trim();
98
+ const match = typeof text === "string" ? text.match(/Ray ID:?\s*(?:<[^>]*>\s*)*([0-9a-f]{16})/i) : null;
99
+ return match ? match[1] : undefined;
100
+ }
101
+
102
+ const looksLikeHtml = (text) => typeof text === "string" && /^\s*<(?:!doctype|html)/i.test(text);
103
+
104
+ /** One call to the board-runner function. Never throws on a network or HTTP
105
+ * failure - every caller gets `{ok:false, error}` back and decides for
106
+ * itself whether that is worth logging, retrying on the next poll, or
107
+ * surfacing on a receipt. `op` and the rest of `body` are sent flat, per the
108
+ * contract's `{op, ...}` shape. */
109
+ async function call(op, body = {}, options = {}) {
110
+ const { env = process.env } = options;
111
+ const { appUrl } = await hostedConfig({ env });
112
+ const url = runnerApiUrlFrom(appUrl, env);
113
+ let result;
114
+ try {
115
+ result = await authenticatedJsonPost(url, { op, ...body }, options);
116
+ } catch (error) {
117
+ // "fetch failed" alone says nothing; undici puts the reason (ENOTFOUND,
118
+ // ECONNRESET, a TLS code) on `cause` (QA 2026-10-01, e8).
119
+ const cause = error?.cause?.code ?? error?.cause?.message;
120
+ // `transient` marks the failures the runner backs off on (ticket e8,
121
+ // lib/runnerPace.mjs): a network failure or timeout here, a 5xx or 429
122
+ // below. A 401 and every refusal leave it unset.
123
+ return { ok: false, transient: true, error: `Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}${cause ? ` (${cause})` : ""}` };
124
+ }
125
+ if (result.authError) return { ok: false, error: result.authError };
126
+
127
+ const { response, text, payload } = result;
128
+ if (!response.ok) {
129
+ // A CLOUDFLARE PAGE (#616): a rate limit answers 429 with a whole HTML
130
+ // page, not JSON. Four hundred characters of markup told nobody
131
+ // anything; say what it is and keep the Ray ID, which is what Cloudflare
132
+ // needs to look the block up.
133
+ const rayId = cloudflareRayId(response.headers, text);
134
+ const html = !payload && looksLikeHtml(text);
135
+ const detail = errorDetail(payload?.error) ?? errorDetail(payload?.message)
136
+ ?? (html ? (response.status === 429 ? "Cloudflare rate limited this computer" : "Cloudflare answered with an error page") : text.slice(0, 400));
137
+ // THE REQUEST ID (QA 2026-10-01, e8): ten 500s overnight read "Could not
138
+ // check that token" and nothing else. The id finds the call in the
139
+ // function's own logs, where board-runner now writes the cause.
140
+ const requestId = response.headers?.get?.("sb-request-id");
141
+ const transient = response.status >= 500 || response.status === 429;
142
+ const wait = transient ? retryAfterMs(response.headers?.get?.("retry-after")) : undefined;
143
+ const ids = [requestId ? `request ${requestId}` : "", rayId ? `Ray ID ${rayId}` : ""].filter(Boolean).join(", ");
144
+ return {
145
+ ok: false,
146
+ ...(transient ? { transient: true } : {}),
147
+ ...(wait !== undefined ? { retryAfterMs: wait } : {}),
148
+ error:
149
+ response.status === 401
150
+ ? `${detail || "Unauthorized."} Sign in again with \`viewable login\`, or mint a new personal access token and set VIEWABLE_BOARD_TOKEN.`
151
+ : `board-runner answered ${response.status}${detail ? `: ${detail}` : ""}${ids ? ` (${ids})` : ""}${wait !== undefined ? `; retry after ${Math.ceil(wait / 1000)}s` : ""}`,
152
+ };
153
+ }
154
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
155
+ return { ok: false, error: `board-runner answered with unreadable JSON: ${text.slice(0, 400)}` };
156
+ }
157
+ if (payload.ok === false) {
158
+ return { ok: false, error: errorDetail(payload.error) ?? "board-runner refused the request." };
159
+ }
160
+ if (payload.ok !== true) return { ok: false, error: "board-runner returned an incomplete response." };
161
+ return payload;
162
+ }
163
+
164
+ // ---------- one function per op, per the Move 2 contract ----------
165
+
166
+ /** `register {runnerId, host, platform, presence, projects:[{path, name?}]}`
167
+ * -> `{projects:[{id, path, name}]}`. Upserts `runner_projects` on
168
+ * `(user, host, path)` and bumps `last_seen_at`. Called on start and every
169
+ * beat with EVERY folder this machine knows about (`~/.glytch/folders.json`),
170
+ * not only the folder a particular watch loop is polling - the upsert is
171
+ * idempotent, so two runner processes registering the same folder twice
172
+ * costs nothing. */
173
+ export function register({ runnerId, host, platform, presence, projects }, options = {}) {
174
+ return call("register", { runnerId, host, platform, presence, projects }, options);
175
+ }
176
+
177
+ /** `heartbeat {projectIds, presence?}` -> `{stops:[runId...], ops:[...]}`.
178
+ * One call per tick covers presence (bumps `last_seen_at` on those project
179
+ * rows), stop signals for every running run on them, and op signals
180
+ * (untaken `run_ops` rows) - replacing `.runner.json`'s beat, `<id>.stop`,
181
+ * and the merge/delete/relaunch/preview signal files all at once. */
182
+ export function heartbeat({ projectIds, presence, runnerInstanceId, activeRunIds } = {}, options = {}) {
183
+ return call("heartbeat", { projectIds, ...(presence !== undefined ? { presence } : {}), ...(runnerInstanceId !== undefined ? { runnerInstanceId } : {}), ...(activeRunIds !== undefined ? { activeRunIds } : {}) }, options);
184
+ }
185
+
186
+ /** `claim {projectIds}` -> `{run}` or `{run:null}`. The oldest approved,
187
+ * unclaimed, pending run on those projects, claimed atomically - replaces
188
+ * `listRequests`/`claimRequest`'s exclusive-create-on-disk race. `run.request`
189
+ * is the SpawnRequest a file used to carry; `run.project_id` names which
190
+ * registered project (and therefore which root) it belongs to. */
191
+ export function claim({ projectIds, runnerInstanceId }, options = {}) {
192
+ return call("claim", { projectIds, ...(runnerInstanceId !== undefined ? { runnerInstanceId } : {}) }, options);
193
+ }
194
+
195
+ /** `update {runId, patch}` -> ok, or `{ok:false, error:"result already
196
+ * written"}` when `patch.result` collides with the write-once rule. `patch`
197
+ * may carry `status`, `sidecar`, `server`, `finished_at`, `result` - this
198
+ * is the one call that replaces `<id>.session.json`, `<id>.server`, and
199
+ * `<id>.result.txt` all at once, plus the `startSession`/`endSession` ops
200
+ * files: the run row IS the start/end stamp now. */
201
+ export function update({ runId, patch }, options = {}) {
202
+ return call("update", { runId, patch }, options);
203
+ }
204
+
205
+ /** `events {runId, events:[{seq, kind, text}]}` -> insert, duplicates on
206
+ * `(run_id, seq)` ignored. Replaces the runner's own append-to-`<id>.log`
207
+ * streaming; `kind` is `"log"` or `"cut"` (an elision marker), matching
208
+ * `run_events.kind`'s check constraint. */
209
+ export function events({ runId, events }, options = {}) {
210
+ return call("events", { runId, events }, options);
211
+ }
212
+
213
+ /** `opTake {opId}` -> `{op}` or `{op:null}`. Sets `taken_at` if it was null
214
+ * and hands back the op row (or null if someone else already took it, or
215
+ * it does not exist/is not this user's) - the row equivalent of consuming a
216
+ * `.merge`/`.delete`/`.relaunch` signal file, or a preview request. */
217
+ export function opTake({ opId }, options = {}) {
218
+ return call("opTake", { opId }, options);
219
+ }
220
+
221
+ /** `opResult {opId, result}` -> ok. merge/delete/relaunch write-once;
222
+ * preview is updatable (its lifecycle ticks through starting/ready/offline/
223
+ * error on one op row rather than one receipt file per state). */
224
+ export function opResult({ opId, result }, options = {}) {
225
+ return call("opResult", { opId, result }, options);
226
+ }
227
+
228
+ /** `runs {projectIds, status?}` -> a list, for maintenance: the sweep's
229
+ * equivalent of scanning `inbox/spawn/` for finished ids, and late
230
+ * promotion's equivalent of finding a session's own row to resume. */
231
+ export function runs({ projectIds, status } = {}, options = {}) {
232
+ return call("runs", { projectIds, ...(status !== undefined ? { status } : {}) }, options);
233
+ }
234
+
235
+ /** `resume {runId, message, images?}` -> inserts an APPROVED child run row
236
+ * copying the parent's request with `resumeSessionId` set, and returns its
237
+ * id. This is Move 2's replacement for `promoteReplyToRequest`/
238
+ * `promoteLateReplies` filing a new request FILE that resumes a session:
239
+ * the runner cannot call the app's `run_request` RPC directly (it holds no
240
+ * Supabase JWT), so this one op does the whole thing server-side under the
241
+ * service role, the same way `claim` and `update` already do. Added to the
242
+ * contract here, dated, because a bare `promoteReplyToRequest`-shaped call
243
+ * would otherwise have no row-side equivalent to land on; see the brief's
244
+ * "Move 2 contract" section, amended 2026-09-11, for the twin of this
245
+ * comment on the function side.
246
+ *
247
+ * `images` (item 6, brief 30 Move 2b, same date): a late message's images,
248
+ * the same shape a reply file's `images` array carried, sent when the
249
+ * caller has any (`rowsAnswers.mjs`'s `promoteLateRows`). The op does not
250
+ * declare this field yet on its own side (its header says why:
251
+ * `session_messages` gaining an `images` column, and `collect_session`
252
+ * exposing it, are another worker's change, not landed on this branch) -
253
+ * sent anyway, because an unrecognised field in a POST body a Deno
254
+ * function does not destructure is simply absent from `body` there, never
255
+ * a hard failure; the day that worker's change lands, this call needs no
256
+ * edit to start actually delivering pictures. */
257
+ export function resume({ runId, message, images, unlessStopped }, options = {}) {
258
+ return call("resume", { runId, message, ...(images?.length > 0 ? { images } : {}), ...(unlessStopped ? { unlessStopped: true } : {}) }, options);
259
+ }
260
+
261
+ /** `presence {paths?}` -> `{projects:[{id, path, host, runner_id,
262
+ * last_seen_at}]}`. Read-only, added 2026-09-12 (brief 31, "doctor asks the
263
+ * board"): the one caller today is `lib/doctor.mjs`'s "daemon beating" row,
264
+ * which used to read a local mark file (`.glytch/inbox/spawn/.runner.json`)
265
+ * that nothing over rows has written since `register`/`heartbeat` replaced
266
+ * it - `runner_projects.last_seen_at` is the truth now, and this is the
267
+ * only op that reads it back to a caller. `paths`, when given, narrows the
268
+ * result to those folders instead of every project this account has ever
269
+ * registered on any machine.
270
+ *
271
+ * It also answers `account:{id, email?}` since 2026-09-14 - WHICH account
272
+ * this bearer actually reaches, as the endpoint that resolved the token
273
+ * sees it. A credential left behind on a previous account looks identical
274
+ * to one on the right account from this side of the wire, which is how two
275
+ * days of session reports landed on a board nobody was watching. See the
276
+ * block over `accountOf` in supabase/functions/board-runner/runner.mjs; the
277
+ * reader is `lib/doctor.mjs`'s "board sign-in" row, which now says who. */
278
+ export function presence({ paths } = {}, options = {}) {
279
+ return call("presence", { ...(paths !== undefined ? { paths } : {}) }, options);
280
+ }
281
+
282
+ /** `forget {projectIds}` -> `{deleted:[id...], kept:[id...]}`. THE DAEMON'S
283
+ * ACKNOWLEDGEMENT, added 2026-09-12 (ruling 3 on the Project panel's
284
+ * picker: "removing must be END TO END, not just hiding the row").
285
+ *
286
+ * A person clicks Remove in the app, which raises `forgotten_at` on the row
287
+ * through its own RPC; the next `heartbeat` hands this daemon a
288
+ * `{kind:"forget", project_id, payload:{path}}` op; the daemon drops that
289
+ * folder from `~/.glytch/folders.json` (lib/registry.mjs's `removeFolder`,
290
+ * which is also what makes multiwatch stop its watcher on the next registry
291
+ * read) and then calls this, and the row goes for good.
292
+ *
293
+ * It is the ONLY op here that ends something rather than recording it, and
294
+ * the server will not act on a project the app has not already flagged - so
295
+ * a daemon cannot forget a folder nobody asked it to, and this call is safe
296
+ * to retry: a second one simply finds nothing pending. */
297
+ export function forget({ projectIds }, options = {}) {
298
+ return call("forget", { projectIds }, options);
299
+ }
300
+
301
+ /** `realtime {}` -> `{url, anonKey, topic, expiresAt, renewAt}` (#811, #813).
302
+ * The project's public address and key, and this account's private topic,
303
+ * live until `expiresAt`. From `renewAt` the same ask hands out the next
304
+ * topic. An older function answers "Unknown op", and the runner keeps its
305
+ * 10 s beat (lib/runnerRealtime.mjs). */
306
+ export function realtime(options = {}) {
307
+ return call("realtime", {}, options);
308
+ }
309
+
310
+ /** Names-only discovery, requested by the person in Settings (#1132). */
311
+ export function computerAppsTake(body, options = {}) { return call("computerAppsTake", body, options); }
312
+ export function computerAppsResult(body, options = {}) { return call("computerAppsResult", body, options); }