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
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
+ }