viewable-one 0.0.0-stage → 0.12.2
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 +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -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 +767 -0
- package/lib/codexApps.mjs +344 -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 +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -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 +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -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 +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -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 +1640 -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 +145 -0
- package/lib/runnerWorkspace.mjs +177 -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 +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- 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,124 @@
|
|
|
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 = ".viewable/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") throw error;
|
|
27
|
+
try { text = await readFile(join(root, ".glytch/agents.json"), "utf8"); }
|
|
28
|
+
catch (legacyError) { if (legacyError.code === "ENOENT") return null; throw legacyError; }
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const saved = JSON.parse(text);
|
|
32
|
+
if (saved.version !== 1 || !Array.isArray(saved.agents)) throw new Error("Invalid shape");
|
|
33
|
+
return parseAgentSelection(saved.agents);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`${SELECTION_FILE} is invalid. Fix it before running init or doctor; nothing was reconfigured.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function saveAgentSelection(root, agents) {
|
|
40
|
+
const selected = parseAgentSelection(agents);
|
|
41
|
+
await mkdir(join(root, ".viewable"), { recursive: true });
|
|
42
|
+
await writeFileAtomic(join(root, SELECTION_FILE), `${JSON.stringify({ version: 1, agents: selected }, null, 2)}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `--no-login` and `--no-agent-login` (brief 31, Moves 1 and 2, 2026-09-12)
|
|
46
|
+
* are the two OFF switches for what `init` now does instead of printing:
|
|
47
|
+
* signing this machine in, and running each agent's own OAuth verb. They are
|
|
48
|
+
* for scripts, for CI, and for a machine that already reaches the board
|
|
49
|
+
* through `VIEWABLE_BOARD_TOKEN`. Default is on, because the whole point of
|
|
50
|
+
* the brief is that a new machine is one command. */
|
|
51
|
+
export function initOptions(args) {
|
|
52
|
+
let agents;
|
|
53
|
+
let yes = false;
|
|
54
|
+
let login = true;
|
|
55
|
+
let agentLogin = true;
|
|
56
|
+
for (let i = 0; i < args.length; i++) {
|
|
57
|
+
const arg = args[i];
|
|
58
|
+
if (arg === "--yes" || arg === "-y") yes = true;
|
|
59
|
+
else if (arg === "--no-login") login = false;
|
|
60
|
+
else if (arg === "--no-agent-login") agentLogin = false;
|
|
61
|
+
else if (arg === "--agents" || arg.startsWith("--agents=")) {
|
|
62
|
+
const value = arg === "--agents" ? args[++i] : arg.slice("--agents=".length);
|
|
63
|
+
if (agents !== undefined || value === undefined) throw new Error("Pass --agents once, followed by a comma-separated list.");
|
|
64
|
+
agents = parseAgentSelection(value);
|
|
65
|
+
} else throw new Error(`Unknown init option: ${arg}`);
|
|
66
|
+
}
|
|
67
|
+
return { agents, yes, login, agentLogin };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// An arrow-key checkbox picker for a real terminal (brief 31, Move 8,
|
|
71
|
+
// 2026-09-12: "when selecting a model I prefer not to type anything: go up
|
|
72
|
+
// and down with arrows to select the right one or even multiple ones").
|
|
73
|
+
// `ask` is kept as the injection point scripts and this file's own tests
|
|
74
|
+
// used before the picker existed - a caller that supplies it gets the exact
|
|
75
|
+
// numbered-list prompt this replaced, so nothing that already depends on
|
|
76
|
+
// typing an answer breaks. Over SSH or a pipe (no TTY), or with `--yes`/
|
|
77
|
+
// `--agents`, no prompt of either kind is ever opened - doctor and the
|
|
78
|
+
// daemon never touch this function's interactive branches.
|
|
79
|
+
export async function chooseAgents({
|
|
80
|
+
options, saved, found, input = process.stdin, output = process.stdout,
|
|
81
|
+
ask, picker = pickMany,
|
|
82
|
+
}) {
|
|
83
|
+
if (options.agents) return options.agents;
|
|
84
|
+
const defaults = saved ?? AGENT_KEYS.filter((key) => found[key]?.found);
|
|
85
|
+
// `--yes` WITH NO AGENT BINARY ON PATH AND NO SAVED SELECTION used to reach
|
|
86
|
+
// `parseAgentSelection([])`, which throws "Choose one or more agents" -
|
|
87
|
+
// uncaught, this took the whole `init` down before the folder was
|
|
88
|
+
// registered or the runner was connected to the board, on a machine whose
|
|
89
|
+
// whole problem is that it has no agent yet (2026-09-19, persona walk).
|
|
90
|
+
// An empty defaults list under `--yes` is not a mistake to refuse; it is
|
|
91
|
+
// "nothing to select automatically", so it returns `[]` rather than
|
|
92
|
+
// throwing - `glytch.mjs`'s `init()` turns that into a warn row naming
|
|
93
|
+
// what to install, and every other step (registry, daemon) still runs.
|
|
94
|
+
if (options.yes) return defaults.length ? parseAgentSelection(defaults) : [];
|
|
95
|
+
if (!input.isTTY || !output.isTTY) {
|
|
96
|
+
if (saved) return saved;
|
|
97
|
+
throw new Error("Choose agents with viewable init --agents claude,codex (or --yes for detected agents) when running without a terminal.");
|
|
98
|
+
}
|
|
99
|
+
output.write("\nConnect coding agents to this project (choose one or more):\n");
|
|
100
|
+
if (ask) {
|
|
101
|
+
PROBE_BINS.forEach((b, i) => output.write(` ${i + 1}. ${b.label} (${found[b.key]?.found ? "installed" : "not detected"})${defaults.includes(b.key) ? " [selected]" : ""}\n`));
|
|
102
|
+
while (true) {
|
|
103
|
+
const question = `Numbers or names, separated by commas${defaults.length ? ` [${defaults.join(",")}]` : ""}: `;
|
|
104
|
+
const answer = await ask(question);
|
|
105
|
+
try {
|
|
106
|
+
if (!answer.trim() && defaults.length) return defaults;
|
|
107
|
+
return parseAgentSelection(answer.split(",").map((s) => {
|
|
108
|
+
const value = s.trim();
|
|
109
|
+
return /^[1-3]$/.test(value) ? AGENT_KEYS[Number(value) - 1] : value;
|
|
110
|
+
}));
|
|
111
|
+
} catch (error) { output.write(`${error.message}\n`); }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const color = colorEnabled({ isTTY: output.isTTY });
|
|
115
|
+
const items = PROBE_BINS.map((b) => ({
|
|
116
|
+
key: b.key,
|
|
117
|
+
label: `${b.label} ${dim(found[b.key]?.found ? "(installed)" : "(not installed)", { color })}`,
|
|
118
|
+
}));
|
|
119
|
+
const chosen = await picker({ items, checked: defaults, input, output, color });
|
|
120
|
+
// Escape/Ctrl-C: the same exit the numbered prompt gave on Ctrl-C (an
|
|
121
|
+
// AbortError from `rl.question`, caught nowhere, dying with exit 1).
|
|
122
|
+
if (chosen === null) throw new Error("Cancelled - no agents connected.");
|
|
123
|
+
return parseAgentSelection(chosen);
|
|
124
|
+
}
|