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.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/hosted.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// THE HOSTED BOARD CLIENT (brief 30, W2a, 2026-09-11). Replaces rowsource.mjs
|
|
2
|
+
// entirely: there is no longer a separate "rows mode" a folder opts into with
|
|
3
|
+
// GLYTCH_BOARD_SOURCE - every agent reaches the board through the hosted
|
|
4
|
+
// endpoint (supabase/functions/board-mcp) whenever there is no local mirror
|
|
5
|
+
// to read instead, and lib/auth.mjs is the one place that resolves a bearer
|
|
6
|
+
// for it (a `glytch login` credential, or VIEWABLE_BOARD_TOKEN for a script).
|
|
7
|
+
//
|
|
8
|
+
// WHY THE ENDPOINT AND NOT POSTGREST DIRECTLY, still: neither a `pmb_`
|
|
9
|
+
// personal access token nor a `pmo_` OAuth token is a Supabase JWT, and
|
|
10
|
+
// PostgREST authenticates JWTs. The Edge Function's own resolve step
|
|
11
|
+
// (supabase/functions/_shared/auth.mjs, shared with board-runner since brief
|
|
12
|
+
// 30 Move 2) is the only thing that turns either shape into a user id, so
|
|
13
|
+
// this file talks to it and nothing else.
|
|
14
|
+
|
|
15
|
+
import { hostedConfig } from "./auth.mjs";
|
|
16
|
+
import { authenticatedJsonPost } from "./authenticatedHttp.mjs";
|
|
17
|
+
import { runnerMcpUrlFrom } from "./mcpjson.mjs";
|
|
18
|
+
export { NOT_SIGNED_IN_HINT } from "./authenticatedHttp.mjs";
|
|
19
|
+
|
|
20
|
+
let nextId = 1;
|
|
21
|
+
|
|
22
|
+
/** Runner-owned calls use the same direct endpoint resolver as run children. */
|
|
23
|
+
export async function callRunnerTool(name, args, options = {}) {
|
|
24
|
+
const env = options.env ?? process.env;
|
|
25
|
+
const config = await hostedConfig({ env });
|
|
26
|
+
return callTool(name, args, { ...options, mcpUrl: runnerMcpUrlFrom(config, env) });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Call one tool on the hosted endpoint and return its text.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} name the tool, e.g. "get_board"
|
|
33
|
+
* @param {object} args
|
|
34
|
+
* @param {{sessionKey?: string, boardId?: string, mcpUrl?: string, env?: object, fetchImpl?: Function, forceRefresh?: boolean}} [options]
|
|
35
|
+
* `sessionKey`, when given, rides as `X-Glytch-Session` - the board-mcp
|
|
36
|
+
* endpoint (2026-09-11) lets it replace the token-derived session key, so a
|
|
37
|
+
* spawned child's tool calls land under the same run key its report(),
|
|
38
|
+
* ask_user and everything else on this run already use.
|
|
39
|
+
* For `get_node`, `boardId` selects a board in the same sequential JSON-RPC
|
|
40
|
+
* batch. Separate requests do not retain the server's board selection. This
|
|
41
|
+
* option is read-only: the server continues a batch after selection errors,
|
|
42
|
+
* so it must never be used for a write. A failed selection refuses the read.
|
|
43
|
+
* @returns {Promise<{text: string, isError: boolean}>}
|
|
44
|
+
*/
|
|
45
|
+
export async function callTool(name, args, options = {}) {
|
|
46
|
+
const { sessionKey, boardId, env = process.env } = options;
|
|
47
|
+
if (boardId !== undefined && (name !== "get_node" || typeof boardId !== "string" || boardId.trim() === "")) {
|
|
48
|
+
return { text: "A board-scoped read requires get_node and a non-empty boardId.", isError: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Resolve before fetching so transport failures name the actual endpoint.
|
|
52
|
+
// This override only affects the MCP POST, never OAuth refresh requests.
|
|
53
|
+
const mcpUrl = options.mcpUrl ?? (await hostedConfig({ env })).mcpUrl;
|
|
54
|
+
const request = { jsonrpc: "2.0", id: nextId++, method: "tools/call", params: { name, arguments: args ?? {} } };
|
|
55
|
+
const select = boardId === undefined ? null : {
|
|
56
|
+
jsonrpc: "2.0", id: nextId++, method: "tools/call",
|
|
57
|
+
params: { name: "get_board", arguments: { board_id: boardId, limit: 1 } },
|
|
58
|
+
};
|
|
59
|
+
const headers = typeof sessionKey === "string" && sessionKey.trim() !== "" ? { "X-Glytch-Session": sessionKey.trim() } : {};
|
|
60
|
+
let result;
|
|
61
|
+
try {
|
|
62
|
+
result = await authenticatedJsonPost(mcpUrl, select ? [select, request] : request, { ...options, headers });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { text: `Could not reach ${mcpUrl}: ${error instanceof Error ? error.message : String(error)}`, isError: true };
|
|
65
|
+
}
|
|
66
|
+
if (result.authError) return { text: result.authError, isError: true };
|
|
67
|
+
const { response, text } = result;
|
|
68
|
+
let { payload } = result;
|
|
69
|
+
if (select && response.ok) {
|
|
70
|
+
const selected = Array.isArray(payload) ? payload.find((part) => part?.id === select.id) : null;
|
|
71
|
+
const answer = Array.isArray(payload) ? payload.find((part) => part?.id === request.id) : null;
|
|
72
|
+
// The endpoint may execute the read after a failed selection. Never
|
|
73
|
+
// mistake its default-board answer for the board the caller requested.
|
|
74
|
+
if (selected?.error || selected?.result?.isError === true) payload = selected;
|
|
75
|
+
else if (!selected?.result || !answer) payload = { error: { message: "The board endpoint returned an incomplete board-scoped response." } };
|
|
76
|
+
else payload = answer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (payload?.error) {
|
|
80
|
+
const extra =
|
|
81
|
+
response.status === 401
|
|
82
|
+
? " Sign in again with `viewable login`, or mint a new personal access token and set VIEWABLE_BOARD_TOKEN."
|
|
83
|
+
: "";
|
|
84
|
+
return { text: `${typeof payload.error === "string" ? payload.error : payload.error.message ?? "The board endpoint refused the request."}${extra}`, isError: true };
|
|
85
|
+
}
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
return {
|
|
88
|
+
text: `The board endpoint answered ${response.status}: ${text.slice(0, 400)}`,
|
|
89
|
+
isError: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(payload?.result?.content)) {
|
|
93
|
+
return { text: "The board endpoint returned an incomplete or unreadable JSON response.", isError: true };
|
|
94
|
+
}
|
|
95
|
+
const content = payload.result.content;
|
|
96
|
+
const joined = content
|
|
97
|
+
.filter((part) => part?.type === "text")
|
|
98
|
+
.map((part) => part.text)
|
|
99
|
+
.join("\n");
|
|
100
|
+
// IMAGE BLOCKS, ALONGSIDE THE TEXT (2026-09-11, brief 30 Move 2b, item 6).
|
|
101
|
+
// No tool on this endpoint sends any today - `collect_session`'s own reply
|
|
102
|
+
// is text-only, and the `session_messages.images` column this would carry
|
|
103
|
+
// is another worker's change, not yet landed here - but the shape is data
|
|
104
|
+
// the wire format (an MCP content array) already allows, so this is
|
|
105
|
+
// forward-compatible plumbing rather than a guess: the day a tool DOES
|
|
106
|
+
// send an image block, every caller of `callTool` picks it up with no
|
|
107
|
+
// change on its own side.
|
|
108
|
+
const images = content
|
|
109
|
+
.filter((part) => part?.type === "image" && typeof part.data === "string")
|
|
110
|
+
.map((part) => ({ mediaType: part.mimeType ?? part.mediaType ?? "image/png", data: part.data }));
|
|
111
|
+
return { text: joined, isError: payload?.result?.isError === true, ...(images.length > 0 ? { images } : {}) };
|
|
112
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// One attachment contract for the runner and all native transports. Validation
|
|
2
|
+
// is deliberately lossless: no remote fetches, format conversions or dropped
|
|
3
|
+
// entries. Decoding verifies transport bytes and image signatures; the engine
|
|
4
|
+
// still has to report if its selected model cannot inspect an image.
|
|
5
|
+
const FORMATS = {
|
|
6
|
+
"image/png": { ext: "png", matches: (b) => b.length >= 24 && b.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) && b.toString("ascii", 12, 16) === "IHDR" && b.readUInt32BE(16) > 0 && b.readUInt32BE(20) > 0 },
|
|
7
|
+
"image/jpeg": { ext: "jpg", matches: (b) => b.length >= 4 && b[0] === 255 && b[1] === 216 && b[2] === 255 && b.at(-2) === 255 && b.at(-1) === 217 },
|
|
8
|
+
"image/gif": { ext: "gif", matches: (b) => b.length >= 13 && /^GIF8[79]a$/.test(b.toString("ascii", 0, 6)) && b.readUInt16LE(6) > 0 && b.readUInt16LE(8) > 0 },
|
|
9
|
+
"image/webp": { ext: "webp", matches: (b) => b.length >= 20 && b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP" && /^VP8[ LX]$/.test(b.toString("ascii", 12, 16)) },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function imageAttachments(images) {
|
|
13
|
+
if (images === undefined) return [];
|
|
14
|
+
if (!Array.isArray(images)) throw new Error("Reference images must be an array of attached image data URLs. Reattach the images and try again.");
|
|
15
|
+
return images.map((url, index) => {
|
|
16
|
+
const fail = (reason) => { throw new Error(`Reference image ${index + 1} ${reason}. Reattach a PNG, JPEG, GIF or WebP image and try again.`); };
|
|
17
|
+
if (typeof url !== "string") fail("is missing its image data");
|
|
18
|
+
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/]*={0,2})$/.exec(url);
|
|
19
|
+
if (!match || !match[2]) fail("has missing or invalid base64 image data");
|
|
20
|
+
const format = FORMATS[match[1]];
|
|
21
|
+
if (!format) fail(`uses an unsupported format (${match[1]})`);
|
|
22
|
+
const bytes = Buffer.from(match[2], "base64");
|
|
23
|
+
if (bytes.length === 0 || bytes.toString("base64").replace(/=+$/, "") !== match[2].replace(/=+$/, "") || (match[2].includes("=") && match[2].length % 4 !== 0)) fail("has invalid base64 image data");
|
|
24
|
+
if (!format.matches(bytes)) fail(`does not contain recognizable ${format.ext.toUpperCase()} image bytes`);
|
|
25
|
+
return { url, mimeType: match[1], data: match[2], bytes, extension: format.ext };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function imageInspectionInstruction(count, planMode) {
|
|
30
|
+
return `\n\nREFERENCE IMAGE INSPECTION${count ? ` (${count} attached image${count === 1 ? "" : "s"})` : ""}\n${count ? "Inspect every attached reference image" : "Check the supplied board context for reference images, including images returned by board tools, and inspect each available reference"} before proposing a plan or editing files. Describe concrete visible observations (layout, content, spacing, colors, or other relevant details) and use them to guide the work. Identify each reference by attachment number or board node/title when recording these observations. Do not infer the image contents from filenames or surrounding text. If any image is missing, unreadable, or unsupported by your tools or model, stop and explicitly report which image you cannot inspect; do not proceed as if you saw it.${planMode === "draft" ? " Include a visual verification step in the plan; do not implement it during this draft." : " For visual or UI changes with references, inspect the final rendered result and compare it against the reference images before reporting completion. Report any differences you could not verify."}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// THE TWO STEPS `glytch init` USED TO ONLY TALK ABOUT (brief 31, Moves 1 and
|
|
2
|
+
// 2, 2026-09-12): signing this machine in, and running each selected agent's
|
|
3
|
+
// own OAuth verb.
|
|
4
|
+
//
|
|
5
|
+
// WHAT WAS MEASURED BEFORE THIS EXISTED. Six user steps to a working machine:
|
|
6
|
+
// install the agents, install the package, `glytch init`, `glytch login`,
|
|
7
|
+
// link the folder, then one OAuth per agent typed by hand from lines `init`
|
|
8
|
+
// printed. Four of those six are this package's own, and three of the four
|
|
9
|
+
// were instructions rather than actions. This module is the three.
|
|
10
|
+
//
|
|
11
|
+
// WHY NOT ONE SHARED TOKEN (ruling 1, same day). Writing one `pmb_`
|
|
12
|
+
// bearer into every agent's config was raised and dropped: the board endpoint
|
|
13
|
+
// carries `send_message` into running sessions and the daemon executes what
|
|
14
|
+
// the board files, so a stolen board token is a way into a coding agent with
|
|
15
|
+
// a shell. Per-agent OAuth stays - each agent keeps its own 1h token,
|
|
16
|
+
// rotating refresh, 30-day silence expiry, hashed at rest, revocable.
|
|
17
|
+
//
|
|
18
|
+
// THE ONE-PASTE PROMPT NARROWS THAT RULING RATHER THAN REVERSING IT
|
|
19
|
+
// (2026-09-19, persona walk finding 1a). The pasting agent runs
|
|
20
|
+
// `VIEWABLE_BOARD_TOKEN=<token> npx -y glytch init --yes` with no TTY, so
|
|
21
|
+
// there is no browser to send OAuth's redirect to and no terminal to wait
|
|
22
|
+
// on it in - the per-agent OAuth verb below can only ever print itself as a
|
|
23
|
+
// hint there. `personalBoardToken` below is that SAME token, already
|
|
24
|
+
// sitting in this process's own env or on disk from `signInStep` a few
|
|
25
|
+
// lines earlier in the same `init` run - not a second, wider-reaching
|
|
26
|
+
// credential minted for this purpose - and it is registered for Claude Code
|
|
27
|
+
// alone, at LOCAL scope: private to this account, ahead of the project
|
|
28
|
+
// entry in Claude Code's own resolution order, and never written into the
|
|
29
|
+
// project file the ruling above was protecting against. codex and opencode still get only the printed
|
|
30
|
+
// verb (AGENT_CONNECT_HINT), because their CLIs were not found on this
|
|
31
|
+
// machine to verify a header-based add against (see AGENT_CONNECT_HINT).
|
|
32
|
+
//
|
|
33
|
+
// EVERYTHING HERE IS INJECTED, the logindaemon.mjs rule: `exec` and `login`
|
|
34
|
+
// are seams, every test asserts the argv sequence, and NOTHING in the suite
|
|
35
|
+
// runs claude, codex, opencode or a browser on the machine running it.
|
|
36
|
+
|
|
37
|
+
import { hostedStatus, login as hostedLogin, readAuthFile } from "./auth.mjs";
|
|
38
|
+
import { projectMcpServerKey } from "./project-mcp.mjs";
|
|
39
|
+
import { execProbe, runInteractive } from "./detect.mjs";
|
|
40
|
+
import { compareVersions } from "./update.mjs";
|
|
41
|
+
import { bold, fail, hint, ok, warn } from "./cli-output.mjs";
|
|
42
|
+
import { BOARD_MCP_URL, entryIsAtRetiredUrl } from "./mcpjson.mjs";
|
|
43
|
+
import { claudeRegistration, moveClaudeRegistration } from "./retired-address.mjs";
|
|
44
|
+
|
|
45
|
+
/** Runtime namespaces stay compatible; only fresh project setup uses the new name (#2328). */
|
|
46
|
+
export const SERVER_KEY = "glytch";
|
|
47
|
+
export const INIT_SERVER_KEY = "viewable";
|
|
48
|
+
|
|
49
|
+
/** `claude mcp login <name>` exists from Claude Code 2.1.186. Below it there
|
|
50
|
+
* is no shell verb at all and the only path is `/mcp` inside a session.
|
|
51
|
+
* Verified 2026-09-12 on 2.1.269 against the board (then prompt.glytch.ai):
|
|
52
|
+
* "Authenticated with "board". Its tools are now available in Claude Code."
|
|
53
|
+
* The line in doctor.mjs that called this unverified was true on 2.1.34. */
|
|
54
|
+
export const CLAUDE_LOGIN_FLOOR = "2.1.186";
|
|
55
|
+
|
|
56
|
+
/** A browser round trip with a person in it. The 5s probe timeout is for
|
|
57
|
+
* `--version`; this one waits for someone to find their password manager. */
|
|
58
|
+
export const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
59
|
+
|
|
60
|
+
/** Per agent: the binary, and the verb that drives its own OAuth flow.
|
|
61
|
+
*
|
|
62
|
+
* WHAT EACH ONE REPORTS ABOUT ALREADY BEING SIGNED IN, asked of the real
|
|
63
|
+
* binaries on this machine 2026-09-12 (claude 2.1.269, codex-cli 0.153.4,
|
|
64
|
+
* opencode 1.18.23) rather than guessed:
|
|
65
|
+
*
|
|
66
|
+
* claude `claude mcp get <name>` prints "Status: ✔ Connected", and
|
|
67
|
+
* `claude mcp list` prints "! Needs authentication" for a server
|
|
68
|
+
* that needs one. So the marker is reliable and we skip on it.
|
|
69
|
+
* opencode `opencode mcp list` prints "⚠ <name> needs authentication" or
|
|
70
|
+
* "✓ <name> connected", per server. Also reliable; also skipped.
|
|
71
|
+
* (`opencode mcp get` does not exist - list is the only asker.)
|
|
72
|
+
* codex NEITHER `codex mcp list` NOR `codex mcp get <name>` says. The
|
|
73
|
+
* list has an `Auth` column, but it names the MECHANISM the
|
|
74
|
+
* transport supports ("OAuth" for an http server, "Unsupported"
|
|
75
|
+
* for stdio), not whether this machine holds a token: an http
|
|
76
|
+
* server nobody has ever logged into still reads "OAuth". So
|
|
77
|
+
* there is nothing to skip on, and the verb runs every time and
|
|
78
|
+
* is left to short-circuit itself.
|
|
79
|
+
*/
|
|
80
|
+
export const AGENT_VERBS = {
|
|
81
|
+
claude: { label: "Claude Code", bin: "claude", verb: ["mcp", "login", INIT_SERVER_KEY] },
|
|
82
|
+
codex: { label: "codex", bin: "codex", verb: ["mcp", "login", INIT_SERVER_KEY] },
|
|
83
|
+
opencode: { label: "opencode", bin: "opencode", verb: ["mcp", "auth", INIT_SERVER_KEY] },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** ONE SENTENCE SHE CAN ACT ON, per agent, when a token-carrying no-terminal
|
|
87
|
+
* registration (below) is not possible for that agent either - the sentence
|
|
88
|
+
* `init`'s own last lines print instead of leaving a warn row buried mid
|
|
89
|
+
* output (2026-09-19, persona walk finding 1b). Claude Code's own in-chat
|
|
90
|
+
* verb is `/mcp`; codex and opencode have no chat-native equivalent, so
|
|
91
|
+
* theirs names the same CLI verb AGENT_VERBS already runs from a terminal. */
|
|
92
|
+
export const AGENT_CONNECT_HINT = {
|
|
93
|
+
claude: "Type /mcp in this tab and sign in to Viewable.",
|
|
94
|
+
codex: "Run `codex mcp login viewable` from a terminal, then restart this session, to connect the board.",
|
|
95
|
+
opencode: "Run `opencode mcp auth viewable` from a terminal to connect the board.",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** A personal `pmb_` token this machine can already reach the board with -
|
|
99
|
+
* `VIEWABLE_BOARD_TOKEN` in this process's own env, or a stored
|
|
100
|
+
* `personal: true` record `login()` already wrote to ~/.glytch/auth.json.
|
|
101
|
+
* Mirrors the order `hostedToken` (lib/auth.mjs) checks in, but returns the
|
|
102
|
+
* raw token rather than a possibly-refreshed OAuth pair: what this needs is
|
|
103
|
+
* a bearer worth handing an agent's OWN config, not this process's own call.
|
|
104
|
+
* `readAuth` is the injection point every test in this file uses. */
|
|
105
|
+
export async function personalBoardToken({ env = process.env, readAuth = readAuthFile } = {}) {
|
|
106
|
+
// Compatibility read: old installs may still set GLYTCH_BOARD_TOKEN (#2196).
|
|
107
|
+
const scripted = typeof (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN) === "string" ? (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN).trim() : "";
|
|
108
|
+
if (scripted !== "") return scripted;
|
|
109
|
+
const stored = await readAuth(env);
|
|
110
|
+
if (stored?.personal === true && typeof stored.accessToken === "string" && stored.accessToken.trim() !== "") {
|
|
111
|
+
return stored.accessToken.trim();
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `claude mcp add`'s own argv for a LOCAL-SCOPE http server carrying a
|
|
117
|
+
* bearer header - verified against `claude mcp add --help` on 2.1.278
|
|
118
|
+
* (2026-09-19). `-s local`, not `user`, and the scope is the whole point:
|
|
119
|
+
* Claude Code resolves a server name local > project > user, and step 2 of
|
|
120
|
+
* this same `init` writes an unauthenticated `glytch` entry into the
|
|
121
|
+
* project's `.mcp.json`. A user-scope entry would be silently shadowed by
|
|
122
|
+
* it; a local-scope one wins. Local scope lives in `~/.claude.json` under
|
|
123
|
+
* this project's path - this account's own file, never the project's
|
|
124
|
+
* `.mcp.json` (which a stranger could commit and read the token from).
|
|
125
|
+
* `--header "Authorization: Bearer <token>"` exactly as the hosted
|
|
126
|
+
* endpoint's own `bearer()` reads it (supabase/functions/board-mcp/index.ts). */
|
|
127
|
+
export function claudeAddArgs(url, token, scope = "local", serverKey = INIT_SERVER_KEY) {
|
|
128
|
+
return ["mcp", "add", "-s", scope, "--transport", "http", serverKey, url, "--header", `Authorization: Bearer ${token}`];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const strip = (value) => String(value ?? "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
132
|
+
|
|
133
|
+
/** The last line of a failed verb's stderr - what the agent actually said,
|
|
134
|
+
* rather than "exited 1". Empty lines and a trailing newline are skipped. */
|
|
135
|
+
export function lastStderrLine(result) {
|
|
136
|
+
const lines = strip(result?.stderr).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
137
|
+
return lines.length ? lines[lines.length - 1] : "the verb failed with no message";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const row = (agent, label, status, text, detail) => ({ agent, label, status, text, ...(detail ? { detail } : {}) });
|
|
141
|
+
|
|
142
|
+
/** One painted line per outcome, in `checkSections`' shape so `init` reads as
|
|
143
|
+
* one list from the first step to the last. */
|
|
144
|
+
export function resultLine({ label, status, text }, options = {}) {
|
|
145
|
+
const tag = status === "ok" ? "OK" : status === "fail" ? "FAIL" : "WARN";
|
|
146
|
+
const paint = status === "ok" ? ok : status === "fail" ? fail : warn;
|
|
147
|
+
return ` ${paint(tag.padEnd(4), options)} ${label} — ${text}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ------------------------------------------------------------------ Move 1
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Sign THIS MACHINE in, if it is not already.
|
|
154
|
+
*
|
|
155
|
+
* `viewable login` stays as its own command for re-auth; this runs the same
|
|
156
|
+
* `login()` from lib/auth.mjs rather than printing "now run viewable login".
|
|
157
|
+
*
|
|
158
|
+
* NOT ON A PIPE. The flow opens a browser and waits on a loopback redirect,
|
|
159
|
+
* which is fine without a terminal, but a script that gets a browser window
|
|
160
|
+
* it never asked for is worse than a script that is told what to run - and
|
|
161
|
+
* `chooseAgents` already sets the precedent for "no terminal, say the command
|
|
162
|
+
* instead". So a non-TTY stdin prints the line and does nothing.
|
|
163
|
+
*/
|
|
164
|
+
export async function signInStep({
|
|
165
|
+
env = process.env,
|
|
166
|
+
status = hostedStatus,
|
|
167
|
+
login = hostedLogin,
|
|
168
|
+
isTTY = Boolean(process.stdin.isTTY),
|
|
169
|
+
skip = false,
|
|
170
|
+
log = () => {},
|
|
171
|
+
color,
|
|
172
|
+
} = {}) {
|
|
173
|
+
const options = color === undefined ? {} : { color };
|
|
174
|
+
// THE ROW COMES FIRST, THEN WHAT TO DO ABOUT IT. A hint printed before its
|
|
175
|
+
// own outcome reads as an instruction for the step that is about to run.
|
|
176
|
+
const settle = (status_, text, next) => {
|
|
177
|
+
const result = row("machine", "Your computer", status_, text);
|
|
178
|
+
log(resultLine(result, options));
|
|
179
|
+
if (next) log(hint(next, options));
|
|
180
|
+
return result;
|
|
181
|
+
};
|
|
182
|
+
// THE SCRIPT DOOR PERSISTS ITSELF (brief 43, 2026-09-19). `hostedStatus`
|
|
183
|
+
// already reads VIEWABLE_BOARD_TOKEN as "signed in" with NOTHING on disk -
|
|
184
|
+
// true for this process, but the daemon `init` is about to hand this folder
|
|
185
|
+
// to is a separate process with no such variable of its own. Route through
|
|
186
|
+
// `login()` whenever the variable is set, even though `status(env)` below
|
|
187
|
+
// would already call this machine signed in, so the credential lands in
|
|
188
|
+
// ~/.glytch/auth.json rather than staying an environment variable nobody
|
|
189
|
+
// downstream inherits. `login()` writes it and returns with no browser and
|
|
190
|
+
// no wait, so this ignores `isTTY` - there is nothing a missing terminal
|
|
191
|
+
// would be protecting against here.
|
|
192
|
+
//
|
|
193
|
+
// `skip` (--no-login) IS STILL HONOURED (2026-09-19, brief 43 review,
|
|
194
|
+
// B10). This used to run ahead of the `skip` check entirely, so a CI box
|
|
195
|
+
// that set VIEWABLE_BOARD_TOKEN and passed `--no-login` specifically to avoid
|
|
196
|
+
// writing a long-lived token to a shared runner's disk got it written
|
|
197
|
+
// anyway. The env var still authenticates THIS process on its own -
|
|
198
|
+
// `hostedToken` checks it first, with no file involved - so `--no-login`
|
|
199
|
+
// costs nothing here but the write.
|
|
200
|
+
// Compatibility read: old installs may still set GLYTCH_BOARD_TOKEN (#2196).
|
|
201
|
+
if (typeof (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN) === "string" && (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN).trim() !== "") {
|
|
202
|
+
if (skip) {
|
|
203
|
+
return settle(
|
|
204
|
+
"warn",
|
|
205
|
+
"skipped (--no-login): VIEWABLE_BOARD_TOKEN still authenticates this process, but was not saved to disk",
|
|
206
|
+
"Run `viewable login` (without --no-login) to save it for this folder.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const stored = await login({ env, log: (line) => log(hint(line, options)) });
|
|
211
|
+
return settle("ok", `VIEWABLE_BOARD_TOKEN saved to ~/.glytch/auth.json (${stored.appUrl})`);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return settle("fail", error instanceof Error ? error.message : String(error));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const current = await status(env);
|
|
218
|
+
if (current?.signedIn) {
|
|
219
|
+
return settle("ok", `already signed in (${current.via ?? "credential on file"})`);
|
|
220
|
+
}
|
|
221
|
+
if (skip) {
|
|
222
|
+
return settle("warn", "skipped (--no-login)", "Run `viewable login` when you want your computer to reach the board.");
|
|
223
|
+
}
|
|
224
|
+
if (!isTTY) {
|
|
225
|
+
return settle("warn", "skipped (no terminal)", "No terminal here. Run `viewable login` from one to sign your computer in.");
|
|
226
|
+
}
|
|
227
|
+
log(bold("Viewable needs a browser sign-in for your computer.", options));
|
|
228
|
+
try {
|
|
229
|
+
const stored = await login({ env, log: (line) => log(hint(line, options)) });
|
|
230
|
+
return settle("ok", `signed in to ${stored.appUrl}`);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return settle("fail", error instanceof Error ? error.message : String(error));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ------------------------------------------------------------------ Move 2
|
|
237
|
+
|
|
238
|
+
/** Does this agent already hold a token for our server? `null` means "this
|
|
239
|
+
* agent cannot say", which is not the same as "no" - see AGENT_VERBS.
|
|
240
|
+
*
|
|
241
|
+
* 2026-10-03: for Claude Code, an entry that still points at the retired
|
|
242
|
+
* prompt.glytch.ai address is NOT connected whatever its status line says
|
|
243
|
+
* (the redirect to app.viewable.one drops the Authorization header). The answer
|
|
244
|
+
* is `{ retired: { scope, bearer } }` when that entry lives in a scope this
|
|
245
|
+
* file may replace (local or user), for the caller to move, and `false` for
|
|
246
|
+
* one in the project file. Any other answer is true, false or null as
|
|
247
|
+
* before. */
|
|
248
|
+
async function alreadyConnected(key, { exec, env, serverKey = INIT_SERVER_KEY }) {
|
|
249
|
+
// The ASKING commands are probes, not browser flows: they keep execProbe's
|
|
250
|
+
// own short timeout rather than LOGIN_TIMEOUT_MS.
|
|
251
|
+
const run = (bin, args) => exec(bin, args, { env });
|
|
252
|
+
if (key === "claude") {
|
|
253
|
+
const result = await run("claude", ["mcp", "get", serverKey]);
|
|
254
|
+
if (!result?.ok) return null;
|
|
255
|
+
const text = strip(`${result.stdout}${result.stderr}`);
|
|
256
|
+
const found = claudeRegistration(text);
|
|
257
|
+
if (entryIsAtRetiredUrl(found, env)) {
|
|
258
|
+
return found.scope === "local" || found.scope === "user" ? { retired: found } : false;
|
|
259
|
+
}
|
|
260
|
+
return !text.includes("Needs authentication");
|
|
261
|
+
}
|
|
262
|
+
if (key === "opencode") {
|
|
263
|
+
const result = await run("opencode", ["mcp", "list"]);
|
|
264
|
+
if (!result?.ok) return null;
|
|
265
|
+
// One entry per server, each opened by "●". Only the entry naming our
|
|
266
|
+
// server answers for it; another server needing auth is not our problem.
|
|
267
|
+
const entry = strip(`${result.stdout}${result.stderr}`)
|
|
268
|
+
.split("●")
|
|
269
|
+
.find((part) => new RegExp(`\\b${serverKey}\\b`).test(part));
|
|
270
|
+
if (!entry) return null;
|
|
271
|
+
return !/needs authentication/i.test(entry);
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Run each selected and FOUND agent's own OAuth verb.
|
|
278
|
+
*
|
|
279
|
+
* A selected agent that is not installed gets no row at all: `init` already
|
|
280
|
+
* prints the detection table above this, and telling someone their missing
|
|
281
|
+
* binary was not logged in is noise. `init` still prints its sign-in line
|
|
282
|
+
* under Next steps for those, exactly as it did before this module existed.
|
|
283
|
+
*
|
|
284
|
+
* THE VERBS NEED A TERMINAL, AND EXACTLY ONE ATTEMPT. `claude mcp login`
|
|
285
|
+
* refused with "stdin isn't a terminal" when run from a pipe on 2.1.269, so
|
|
286
|
+
* they run through `runInteractive` - stdin and stdout are the person's own
|
|
287
|
+
* terminal, stderr stays piped so a failure still has a line to report, and
|
|
288
|
+
* the command is resolved once and spawned once (2026-09-12 review, F3: as a
|
|
289
|
+
* mode of `execProbe` these inherited its `claude` PATH-miss retry, so a
|
|
290
|
+
* consent declined in the browser opened a second browser window). The ASKING
|
|
291
|
+
* commands above are probes and stay on `exec`. With no TTY the verb is
|
|
292
|
+
* printed as a hint instead of run.
|
|
293
|
+
*/
|
|
294
|
+
export async function agentLoginStep({
|
|
295
|
+
root = null,
|
|
296
|
+
selected = [],
|
|
297
|
+
agents = {},
|
|
298
|
+
exec = execProbe,
|
|
299
|
+
interactive = runInteractive,
|
|
300
|
+
env = process.env,
|
|
301
|
+
isTTY = Boolean(process.stdin.isTTY),
|
|
302
|
+
skip = false,
|
|
303
|
+
timeoutMs = LOGIN_TIMEOUT_MS,
|
|
304
|
+
// A personal token this run can hand an agent's own config, and the URL to
|
|
305
|
+
// register it against - both default to the same sources `signInStep`/
|
|
306
|
+
// `mergeBoardServer` use, injectable so a test never reads a real
|
|
307
|
+
// ~/.glytch/auth.json or depends on GLYTCH_BOARD_URL being unset.
|
|
308
|
+
token = null,
|
|
309
|
+
boardUrl = BOARD_MCP_URL(env),
|
|
310
|
+
log = () => {},
|
|
311
|
+
color,
|
|
312
|
+
} = {}) {
|
|
313
|
+
const options = color === undefined ? {} : { color };
|
|
314
|
+
const rows = [];
|
|
315
|
+
for (const key of Object.keys(AGENT_VERBS)) {
|
|
316
|
+
if (!selected.includes(key)) continue;
|
|
317
|
+
const found = agents?.[key];
|
|
318
|
+
if (!found?.found) continue;
|
|
319
|
+
const serverKey = await projectMcpServerKey(root, key);
|
|
320
|
+
const { label, bin } = AGENT_VERBS[key];
|
|
321
|
+
const verb = [...AGENT_VERBS[key].verb.slice(0, -1), serverKey];
|
|
322
|
+
const command = `${bin} ${verb.join(" ")}`;
|
|
323
|
+
// The row first, then what to do about it - see signInStep above.
|
|
324
|
+
const settle = (status, text, next) => {
|
|
325
|
+
const result = row(key, label, status, text);
|
|
326
|
+
rows.push(result);
|
|
327
|
+
log(resultLine(result, options));
|
|
328
|
+
if (next) log(hint(next, options));
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// A registration at the retired address is replaced, not kept, and before
|
|
332
|
+
// the gates below: skipping sign-in must not leave it on prompt.glytch.ai.
|
|
333
|
+
let connected = key === "claude" ? await alreadyConnected(key, { exec, env, serverKey }) : undefined;
|
|
334
|
+
if (connected !== null && typeof connected === "object") {
|
|
335
|
+
const moved = await moveClaudeRegistration({
|
|
336
|
+
exec, bin, env, serverKey, ...connected.retired, token, boardUrl,
|
|
337
|
+
addArgs: (url, bearer, scope) => claudeAddArgs(url, bearer, scope, serverKey), stderrOf: lastStderrLine,
|
|
338
|
+
});
|
|
339
|
+
if (moved.status !== "relogin") {
|
|
340
|
+
settle(moved.status, moved.message);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
log(hint(moved.message, options));
|
|
344
|
+
connected = false;
|
|
345
|
+
}
|
|
346
|
+
if (skip) {
|
|
347
|
+
settle("warn", "skipped (--no-agent-login)", `Run \`${command}\` when you want it connected.`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// THE ONE VERSION FLOOR. `claude mcp login` does not exist below 2.1.186
|
|
352
|
+
// and an unreadable version is treated as below it: guessing high here
|
|
353
|
+
// costs a confusing "unknown command" where guessing low costs one extra
|
|
354
|
+
// printed line.
|
|
355
|
+
if (key === "claude" && compareVersions(found.version ?? "0.0.0", CLAUDE_LOGIN_FLOOR) < 0) {
|
|
356
|
+
const seen = found.version ?? "an unreadable version";
|
|
357
|
+
settle(
|
|
358
|
+
"warn",
|
|
359
|
+
`skipped (Claude Code ${seen} < ${CLAUDE_LOGIN_FLOOR})`,
|
|
360
|
+
`Open a session on this project, run /mcp, and authenticate the ${serverKey} connection. Upgrade Claude Code to ${CLAUDE_LOGIN_FLOOR} or newer to skip this step.`,
|
|
361
|
+
);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (connected === undefined) connected = await alreadyConnected(key, { exec, env, serverKey });
|
|
366
|
+
if (connected === true) {
|
|
367
|
+
settle("ok", "already connected");
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!isTTY) {
|
|
372
|
+
// THE PASTING AGENT HAS NO TTY EITHER (fix 1a, 2026-09-19 persona
|
|
373
|
+
// walk): OAuth needs a browser AND a terminal to wait on the redirect
|
|
374
|
+
// in, and `glytch init --yes` run BY a coding agent's own shell tool
|
|
375
|
+
// has neither. A personal token already on this machine skips OAuth
|
|
376
|
+
// entirely for Claude Code - the one agent whose CLI a bearer header
|
|
377
|
+
// was verified against (`claudeAddArgs` above) - registering it at
|
|
378
|
+
// LOCAL scope: it wins over the unauthenticated project entry and never lands in a committed file.
|
|
379
|
+
if (key === "claude" && token) {
|
|
380
|
+
const addArgs = claudeAddArgs(boardUrl, token, "local", serverKey);
|
|
381
|
+
let added;
|
|
382
|
+
try {
|
|
383
|
+
added = await exec(bin, addArgs, { env });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
settle("fail", error instanceof Error ? error.message : String(error));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (added?.ok) {
|
|
389
|
+
settle("ok", `registered at local scope with a personal access token (${bin} ${addArgs.slice(0, 6).join(" ")} ...)`);
|
|
390
|
+
} else {
|
|
391
|
+
settle("fail", lastStderrLine(added));
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
settle("warn", "skipped (no terminal)", `Run \`${command}\` from a terminal to connect it.`);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
log(bold(`${label} is asking for a browser sign-in.`, options));
|
|
400
|
+
log(hint(command, options));
|
|
401
|
+
// A SPAWN THAT THROWS IS THIS AGENT'S FAIL ROW, NOT THE END OF `init`
|
|
402
|
+
// (2026-09-12 review, F4). `runInteractive` resolves for a child that ran
|
|
403
|
+
// and failed, but a spawn can throw outright - EACCES on a binary that is
|
|
404
|
+
// not executable, ENOENT for one deleted between the probe and here - and
|
|
405
|
+
// an uncaught throw took the whole command down through `main().catch`,
|
|
406
|
+
// losing the rows already painted above it and the agents after it.
|
|
407
|
+
let result;
|
|
408
|
+
try {
|
|
409
|
+
result = await interactive(bin, verb, { timeoutMs, env });
|
|
410
|
+
} catch (error) {
|
|
411
|
+
settle("fail", error instanceof Error ? error.message : String(error));
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (result?.ok) settle("ok", "connected");
|
|
415
|
+
else settle("fail", lastStderrLine(result));
|
|
416
|
+
}
|
|
417
|
+
return rows;
|
|
418
|
+
}
|