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,105 @@
1
+ import { mkdir, writeFile, unlink, link, open } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { hostname } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import { SPAWN_DIR } from "./spawn.mjs";
6
+
7
+ const deadLocalOwner = (owner) => {
8
+ if (owner?.host !== hostname() || !Number.isInteger(owner.pid) || owner.pid <= 0) return false;
9
+ try { process.kill(owner.pid, 0); return false; }
10
+ catch (error) { return error?.code === "ESRCH"; }
11
+ };
12
+
13
+ /** The bytes at `path` AND the identity of the file they came off, read
14
+ * through ONE handle so the two can never describe different files.
15
+ *
16
+ * WHY ONE HANDLE. This used to be a readFile followed by a stat of the same
17
+ * path, and the gap between them was a hole big enough to lose the whole
18
+ * guarantee through: a caller could read the torn bytes, lose the recovery
19
+ * race, and then stat the path again and get the INODE OF THE WINNER'S LIVE
20
+ * LOCK. The generation it filed a cleaner marker under was that winner's, so
21
+ * its marker was unclaimed, it unlinked a lock that was alive and healthy,
22
+ * and took the room for itself. Two holders, which is the one outcome this
23
+ * whole module exists to make impossible. Reading the size and the inode off
24
+ * an open descriptor makes the record and its identity the same observation.
25
+ *
26
+ * Null where there is nothing to read, which leaves the lock alone. */
27
+ const readLock = async (path) => {
28
+ const handle = await open(path, "r");
29
+ try {
30
+ const info = await handle.stat();
31
+ return { raw: await handle.readFile("utf8"), generation: `inode-${info.ino}` };
32
+ } finally {
33
+ await handle.close().catch(() => {});
34
+ }
35
+ };
36
+
37
+ /** Serialize admission, not execution. Claims reserve budgets and rooms after
38
+ * release. Publish a complete owner via an exclusive hard link: a reader can
39
+ * never mistake an incomplete owner write for a dead process. The directory
40
+ * override lets registry restores reuse this protocol apart from run claims. */
41
+ export async function acquireAdmission(root, { directory = join(root, SPAWN_DIR) } = {}) {
42
+ const dir = directory;
43
+ const path = join(dir, ".admission.lock");
44
+ const token = randomUUID();
45
+ const temporary = join(dir, `.admission-owner-${token}`);
46
+ await mkdir(dir, { recursive: true });
47
+ await writeFile(temporary, JSON.stringify({ pid: process.pid, host: hostname(), token }), { flag: "wx" });
48
+ try {
49
+ for (;;) {
50
+ try {
51
+ await link(temporary, path);
52
+ return { ok: true, release: () => unlink(path) };
53
+ } catch (error) {
54
+ if (error?.code !== "EEXIST") throw error;
55
+ }
56
+ let owner;
57
+ // A lock we could not read at all, kept apart from one we read and could
58
+ // not parse. The first may still be a live holder and is never stolen;
59
+ // the second cannot be, because a live holder publishes a COMPLETE owner
60
+ // by hard link and never a half of one.
61
+ let unreadable = null;
62
+ let corrupt = null;
63
+ try {
64
+ const { raw, generation: inode } = await readLock(path);
65
+ try { owner = JSON.parse(raw); }
66
+ catch { corrupt = inode; }
67
+ } catch (error) {
68
+ if (error?.code === "ENOENT") continue;
69
+ unreadable = error;
70
+ }
71
+ // The generation being recovered, and the key its one cleaner marker is
72
+ // filed under. A dead owner supplies its own token; a torn record cannot
73
+ // supply anything, so the inode it is written on IS its generation -
74
+ // unlinking frees it and the next holder is a different inode entirely.
75
+ const generation =
76
+ deadLocalOwner(owner) && /^[a-f0-9-]{36}$/.test(owner.token ?? "") ? owner.token : corrupt;
77
+ if (generation) {
78
+ // One cleaner for this exact dead generation. Keep its tiny marker:
79
+ // another delayed cleaner must never unlink a newer holder's lock.
80
+ // The marker is only worth that much while `generation` provably
81
+ // names the file the BYTES came off, which is what readLock's single
82
+ // handle buys and what a second stat of the path did not.
83
+ try {
84
+ await writeFile(join(dir, `.admission-recovered-${generation}`), "recovered\n", { flag: "wx" });
85
+ await unlink(path);
86
+ continue;
87
+ } catch (error) {
88
+ if (error?.code !== "EEXIST" && error?.code !== "ENOENT") throw error;
89
+ }
90
+ }
91
+ // WHY it is busy, in the answer itself. A refusal that names only the
92
+ // path leaves a caller guessing between a live holder and a lock this
93
+ // process cannot read, which is the difference between waiting and
94
+ // fixing something.
95
+ const because = unreadable
96
+ ? ` The lock could not be read (${unreadable.code ?? unreadable.message}).`
97
+ : owner
98
+ ? ` Held by pid ${owner.pid} on ${owner.host}.`
99
+ : "";
100
+ return { ok: false, detail: `Admission is busy (${path}).${because} Retry on the next poll. An owner on another host or an unreadable lock needs manual recovery after confirming it is stopped.` };
101
+ }
102
+ } finally {
103
+ await unlink(temporary).catch(() => {});
104
+ }
105
+ }
@@ -0,0 +1,123 @@
1
+ // Project-owned MCP setup choices. A missing file means a legacy install;
2
+ // malformed choices must never turn into permission to configure every agent.
3
+ import { mkdir, readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { PROBE_BINS } from "./detect.mjs";
6
+ import { writeFileAtomic } from "./atomic.mjs";
7
+ import { colorEnabled, dim } from "./cli-output.mjs";
8
+ import { pickMany } from "./picker.mjs";
9
+
10
+ export const SELECTION_FILE = ".glytch/agents.json";
11
+ export const AGENT_KEYS = PROBE_BINS.map((b) => b.key);
12
+
13
+ export function parseAgentSelection(value) {
14
+ const keys = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
15
+ if (!keys.length || keys.some((key) => !AGENT_KEYS.includes(key))) {
16
+ throw new Error(`Choose one or more agents: ${AGENT_KEYS.join(", ")}. Example: viewable init --agents claude,codex`);
17
+ }
18
+ return [...new Set(keys)];
19
+ }
20
+
21
+ export async function readAgentSelection(root) {
22
+ let text;
23
+ try {
24
+ text = await readFile(join(root, SELECTION_FILE), "utf8");
25
+ } catch (error) {
26
+ if (error.code === "ENOENT") return null;
27
+ throw error;
28
+ }
29
+ try {
30
+ const saved = JSON.parse(text);
31
+ if (saved.version !== 1 || !Array.isArray(saved.agents)) throw new Error("Invalid shape");
32
+ return parseAgentSelection(saved.agents);
33
+ } catch {
34
+ throw new Error(`${SELECTION_FILE} is invalid. Fix it before running init or doctor; nothing was reconfigured.`);
35
+ }
36
+ }
37
+
38
+ export async function saveAgentSelection(root, agents) {
39
+ const selected = parseAgentSelection(agents);
40
+ await mkdir(join(root, ".glytch"), { recursive: true });
41
+ await writeFileAtomic(join(root, SELECTION_FILE), `${JSON.stringify({ version: 1, agents: selected }, null, 2)}\n`);
42
+ }
43
+
44
+ /** `--no-login` and `--no-agent-login` (brief 31, Moves 1 and 2, 2026-09-12)
45
+ * are the two OFF switches for what `init` now does instead of printing:
46
+ * signing this machine in, and running each agent's own OAuth verb. They are
47
+ * for scripts, for CI, and for a machine that already reaches the board
48
+ * through `VIEWABLE_BOARD_TOKEN`. Default is on, because the whole point of
49
+ * the brief is that a new machine is one command. */
50
+ export function initOptions(args) {
51
+ let agents;
52
+ let yes = false;
53
+ let login = true;
54
+ let agentLogin = true;
55
+ for (let i = 0; i < args.length; i++) {
56
+ const arg = args[i];
57
+ if (arg === "--yes" || arg === "-y") yes = true;
58
+ else if (arg === "--no-login") login = false;
59
+ else if (arg === "--no-agent-login") agentLogin = false;
60
+ else if (arg === "--agents" || arg.startsWith("--agents=")) {
61
+ const value = arg === "--agents" ? args[++i] : arg.slice("--agents=".length);
62
+ if (agents !== undefined || value === undefined) throw new Error("Pass --agents once, followed by a comma-separated list.");
63
+ agents = parseAgentSelection(value);
64
+ } else throw new Error(`Unknown init option: ${arg}`);
65
+ }
66
+ return { agents, yes, login, agentLogin };
67
+ }
68
+
69
+ // An arrow-key checkbox picker for a real terminal (brief 31, Move 8,
70
+ // 2026-09-12: "when selecting a model I prefer not to type anything: go up
71
+ // and down with arrows to select the right one or even multiple ones").
72
+ // `ask` is kept as the injection point scripts and this file's own tests
73
+ // used before the picker existed - a caller that supplies it gets the exact
74
+ // numbered-list prompt this replaced, so nothing that already depends on
75
+ // typing an answer breaks. Over SSH or a pipe (no TTY), or with `--yes`/
76
+ // `--agents`, no prompt of either kind is ever opened - doctor and the
77
+ // daemon never touch this function's interactive branches.
78
+ export async function chooseAgents({
79
+ options, saved, found, input = process.stdin, output = process.stdout,
80
+ ask, picker = pickMany,
81
+ }) {
82
+ if (options.agents) return options.agents;
83
+ const defaults = saved ?? AGENT_KEYS.filter((key) => found[key]?.found);
84
+ // `--yes` WITH NO AGENT BINARY ON PATH AND NO SAVED SELECTION used to reach
85
+ // `parseAgentSelection([])`, which throws "Choose one or more agents" -
86
+ // uncaught, this took the whole `init` down before the folder was
87
+ // registered or the runner was connected to the board, on a machine whose
88
+ // whole problem is that it has no agent yet (2026-09-19, persona walk).
89
+ // An empty defaults list under `--yes` is not a mistake to refuse; it is
90
+ // "nothing to select automatically", so it returns `[]` rather than
91
+ // throwing - `glytch.mjs`'s `init()` turns that into a warn row naming
92
+ // what to install, and every other step (registry, daemon) still runs.
93
+ if (options.yes) return defaults.length ? parseAgentSelection(defaults) : [];
94
+ if (!input.isTTY || !output.isTTY) {
95
+ if (saved) return saved;
96
+ throw new Error("Choose agents with viewable init --agents claude,codex (or --yes for detected agents) when running without a terminal.");
97
+ }
98
+ output.write("\nConnect coding agents to this project (choose one or more):\n");
99
+ if (ask) {
100
+ PROBE_BINS.forEach((b, i) => output.write(` ${i + 1}. ${b.label} (${found[b.key]?.found ? "installed" : "not detected"})${defaults.includes(b.key) ? " [selected]" : ""}\n`));
101
+ while (true) {
102
+ const question = `Numbers or names, separated by commas${defaults.length ? ` [${defaults.join(",")}]` : ""}: `;
103
+ const answer = await ask(question);
104
+ try {
105
+ if (!answer.trim() && defaults.length) return defaults;
106
+ return parseAgentSelection(answer.split(",").map((s) => {
107
+ const value = s.trim();
108
+ return /^[1-3]$/.test(value) ? AGENT_KEYS[Number(value) - 1] : value;
109
+ }));
110
+ } catch (error) { output.write(`${error.message}\n`); }
111
+ }
112
+ }
113
+ const color = colorEnabled({ isTTY: output.isTTY });
114
+ const items = PROBE_BINS.map((b) => ({
115
+ key: b.key,
116
+ label: `${b.label} ${dim(found[b.key]?.found ? "(installed)" : "(not installed)", { color })}`,
117
+ }));
118
+ const chosen = await picker({ items, checked: defaults, input, output, color });
119
+ // Escape/Ctrl-C: the same exit the numbered prompt gave on Ctrl-C (an
120
+ // AbortError from `rl.question`, caught nowhere, dying with exit 1).
121
+ if (chosen === null) throw new Error("Cancelled - no agents connected.");
122
+ return parseAgentSelection(chosen);
123
+ }