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.
Files changed (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -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 +767 -0
  20. package/lib/codexApps.mjs +344 -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 +629 -0
  28. package/lib/devserver.mjs +670 -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/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
package/lib/sync.mjs ADDED
@@ -0,0 +1,484 @@
1
+ // PUT BACK WHAT IS MISSING, TOUCH NOTHING THAT IS THERE.
2
+ //
3
+ // Ruling 5's second duty (2026-09-06). The four things a folder needs in
4
+ // order for a session to run in it are each written by a different act, at a
5
+ // different time, by a different tool - and each of them can go away on its
6
+ // own. `.mcp.json` is lost to a `git clean`. The opencode registration lives
7
+ // in opencode's own store and a reinstall drops it. The registry entry goes
8
+ // when someone edits folders.json. The plist's captured PATH goes stale the
9
+ // day Homebrew is installed. Nothing noticed any of that until a session
10
+ // failed.
11
+ //
12
+ // So the daemon re-checks all four on every start, and `init` and `doctor`
13
+ // call exactly the same function - one implementation, so the three cannot
14
+ // drift into three different opinions about what a folder needs.
15
+ //
16
+ // THE RULE IS ONE-WAY. A missing thing is written; a thing that is there is
17
+ // read and reported and left exactly as it is. The one place that is not
18
+ // literal is the plist's PATH, and it is not an exception so much as the
19
+ // same rule applied to the CONTENTS of the file: a PATH that has lost entries
20
+ // the shell now has is missing those entries, and they are added back as a
21
+ // union so nothing already captured is dropped. That trade is deliberate and
22
+ // it is the launchd PATH trap; see lib/logindaemon.mjs.
23
+ //
24
+ // 2026-09-07, brief 21 session 4: the fourth row is no longer launchd. It
25
+ // asks lib/logindaemon.mjs for THIS machine's implementation - a LaunchAgent
26
+ // on macOS, a Task Scheduler onlogon task on Windows (ruling 1A), a systemd
27
+ // user unit on WSL and Linux (ruling 6C) - and the logic below is the same
28
+ // three questions on all three: is the file there, does its captured PATH
29
+ // still cover this shell, and is the OS actually holding it. The row's KEY
30
+ // stays "plist" because that is the contract doctor and the tests read; its
31
+ // LABEL is what the platform calls the thing.
32
+
33
+ import { readFile, realpath, writeFile } from "node:fs/promises";
34
+ import { join } from "node:path";
35
+ import { detectAgents, execProbe } from "./detect.mjs";
36
+ import {
37
+ DAEMON_LABEL,
38
+ RUNNER_BY_HAND,
39
+ daemonPackageVersion,
40
+ viewableScriptPath,
41
+ loginDaemon,
42
+ noDaemonInstall,
43
+ } from "./logindaemon.mjs";
44
+ import { BOARD_MCP_URL, DEFAULT_BOARD_MCP_URL, mergeBoardServer } from "./mcpjson.mjs";
45
+ import { addFolder, readRegistry } from "./registry.mjs";
46
+ import { readAgentSelection, parseAgentSelection } from "./agent-selection.mjs";
47
+ import { syncProjectMcp } from "./project-mcp.mjs";
48
+ import { packageVersion } from "./update.mjs";
49
+ import { readRunnerHealth } from "./runner-health.mjs";
50
+
51
+ export const MCP_FILE = ".mcp.json";
52
+ /** What opencode's own CLI is told to register, for the legacy repair path
53
+ * (a folder with no saved agent selection - `syncOpencode` below, not the
54
+ * per-project `opencode.json` `syncProjectMcp` writes). The board is a
55
+ * hosted URL, so this registers it the way `opencode mcp add --help`
56
+ * documents a remote server (`--url`), with no process to spawn.
57
+ *
58
+ * RENAMED 2026-09-12 (brief 31, Move 3): the name is `glytch`, not `board`. */
59
+ export const OPENCODE_ADD = ["mcp", "add", "glytch", "--url", BOARD_MCP_URL()];
60
+
61
+ /** `action` is the one next step THIS row needs, when the generic "run
62
+ * viewable init" is wrong - a denied Task Scheduler registration being the case
63
+ * that named it (2026-09-11): init runs the same refused command, so the
64
+ * default advice was a loop. lib/cli-output.mjs prefers it over its own. */
65
+ const row = (key, label, ok, state, detail = "", action) =>
66
+ action ? { key, label, ok, state, detail, action } : { key, label, ok, state, detail };
67
+
68
+ /** `.mcp.json` in this folder, by the app's rules.
69
+ *
70
+ * 2026-09-11 (brief 30): the shape is one hosted URL on every platform now
71
+ * (lib/mcpjson.mjs), so `platform` stopped being the axis that mattered here
72
+ * - it is still accepted, unused, because every caller already threads one
73
+ * through for `syncLaunchAgent`'s sake and removing the parameter would
74
+ * touch every call site for no behavioural gain. `env` is the new axis
75
+ * (`GLYTCH_BOARD_URL`, for a dev pointing at another deployment), for the
76
+ * same reason `platform` used to be one: a test has to be able to say which,
77
+ * rather than inheriting whatever happens to be set in the runner's shell.
78
+ * A pre-existing `board` entry naming the retired local server is upgraded
79
+ * in place (`merge.kind === "legacy"`) rather than left to name a server
80
+ * that no longer runs; any other custom entry is still left exactly as it
81
+ * is, unchanged since before this ruling. */
82
+ export async function syncMcpJson(root, { write = true, platform = process.platform, env = process.env } = {}) {
83
+ const path = join(root, MCP_FILE);
84
+ let existing = null;
85
+ try {
86
+ existing = await readFile(path, "utf8");
87
+ } catch {
88
+ existing = null;
89
+ }
90
+ const merge = mergeBoardServer(existing, env);
91
+ if (merge.kind === "present") return row("mcp", ".mcp.json", true, "present", "board connection already registered");
92
+ if (merge.kind === "unreadable") {
93
+ return row(
94
+ "mcp",
95
+ ".mcp.json",
96
+ false,
97
+ "unreadable",
98
+ `${path} exists and is not JSON we can extend. It is someone's work, so nothing was written - open it and add {"mcpServers":{"viewable":{"type":"http","url":"${DEFAULT_BOARD_MCP_URL}"}}} by hand.`,
99
+ );
100
+ }
101
+ if (!write) {
102
+ return merge.kind === "legacy"
103
+ ? row("mcp", ".mcp.json", false, "legacy-command", `${path} still names the retired ${merge.moved ? "board address - run viewable init to move it to app.viewable.one" : "local MCP server - run viewable init to switch it to the hosted board URL"}`)
104
+ : row("mcp", ".mcp.json", false, merge.kind, "missing the Viewable server");
105
+ }
106
+ await writeFile(path, merge.text, "utf8");
107
+ return merge.kind === "legacy"
108
+ ? row("mcp", ".mcp.json", true, "upgraded", `glytch entry ${merge.moved ? "moved to app.viewable.one" : "upgraded from the old command shape to the hosted URL"}`)
109
+ : row("mcp", ".mcp.json", true, merge.kind, merge.kind === "create" ? "written" : "viewable entry added beside what was there");
110
+ }
111
+
112
+ /** opencode's own MCP registry. It does not read .mcp.json, so this is the
113
+ * whole of its setup - and it is skipped, green, on a machine that has no
114
+ * opencode: a folder does not need a registration for a tool nobody has. */
115
+ export async function syncOpencode(root, { exec = execProbe, found, write = true } = {}) {
116
+ if (!found) return row("opencode", "opencode glytch server", true, "absent", "opencode is not installed - nothing to register");
117
+ if (!exec) return row("opencode", "opencode glytch server", true, "unknown", "not checked");
118
+ const listed = await exec("opencode", ["mcp", "list"], { cwd: root });
119
+ // RENAMED 2026-09-12 (brief 31, Move 3): opencode registers the server as
120
+ // `glytch` now (OPENCODE_ADD above), so that is the name its own `mcp
121
+ // list` reports back when it is already there.
122
+ const already = listed?.ok === true && /\bglytch\b/.test(`${listed.stdout ?? ""}${listed.stderr ?? ""}`);
123
+ if (already) return row("opencode", "opencode glytch server", true, "present", "glytch entry registered with opencode");
124
+ if (!write) return row("opencode", "opencode glytch server", false, "missing", "glytch entry not registered with opencode");
125
+ const added = await exec("opencode", OPENCODE_ADD, { cwd: root });
126
+ return added?.ok === true
127
+ ? row("opencode", "opencode glytch server", true, "added", `ran: opencode ${OPENCODE_ADD.join(" ")}`)
128
+ : row("opencode", "opencode glytch server", false, "failed", (added?.stderr ?? "").trim() || "opencode mcp add failed");
129
+ }
130
+
131
+ /** This folder's entry in ~/.glytch/folders.json.
132
+ *
133
+ * `addFolder` can THROW - a Windows profile with a locked-down `%USERPROFILE%`
134
+ * (a managed machine, OneDrive redirecting the folder and briefly denying a
135
+ * write) refuses the file the same way `schtasks` refuses an unelevated
136
+ * registration - and until this pass that exception reached `doctor`/`init`
137
+ * uncaught: a stack trace instead of a row, and a project that silently never
138
+ * became "a runner project" because nothing here ever told the person why. A
139
+ * write failure is now the same shape every other row in this file uses -
140
+ * red, with the OS's own reason and something to try - never a crash. */
141
+ export async function syncRegistry(root, { env = process.env, write = true } = {}) {
142
+ const folders = await readRegistry(env);
143
+ if (folders.includes(root)) return row("registry", "folder registered", true, "present", "your computer watches this folder");
144
+ if (!write) return row("registry", "folder registered", false, "missing", "this folder is not in ~/.glytch/folders.json");
145
+ try {
146
+ await addFolder(root, env);
147
+ } catch (error) {
148
+ const why = error instanceof Error ? error.message : String(error);
149
+ return row(
150
+ "registry",
151
+ "folder registered",
152
+ false,
153
+ "could not write",
154
+ `~/.glytch/folders.json could not be written: ${why}`,
155
+ "Check that ~/.glytch is writable by this account (on Windows, a locked-down or OneDrive-redirected profile can refuse it), then rerun viewable init.",
156
+ );
157
+ }
158
+ return row("registry", "folder registered", true, "added", "added to ~/.glytch/folders.json");
159
+ }
160
+
161
+ /** The entries of a PATH, in order, without the empties.
162
+ *
163
+ * 2026-09-07: the delimiter is an argument because a Windows PATH is `;`
164
+ * separated and its entries contain `C:`. It defaults to `:` rather than to
165
+ * `path.delimiter` on purpose - what these three functions are asked about is
166
+ * the PATH inside a plist, a launcher or a unit, which belongs to the
167
+ * IMPLEMENTATION being synced and not to the platform this process happens to
168
+ * be running on, so the caller passes impl.pathDelimiter. */
169
+ export const pathEntries = (value, delimiter = ":") =>
170
+ String(value ?? "").split(delimiter).filter((e) => e !== "");
171
+
172
+ /** What this shell has that the plist did not capture. ORDER IS NOT THE
173
+ * QUESTION: a plist capturing "/opt/homebrew/bin:/usr/bin" already covers a
174
+ * shell whose PATH is "/usr/bin:/opt/homebrew/bin", and rewriting it because
175
+ * the strings differ would rewrite the plist on every doctor run for ever. */
176
+ export const missingPathEntries = (captured, current, delimiter = ":") => {
177
+ const have = new Set(pathEntries(captured, delimiter));
178
+ return pathEntries(current, delimiter).filter((entry) => !have.has(entry));
179
+ };
180
+
181
+ /** The union of two PATHs, the shell's order first, nothing dropped. */
182
+ export function mergePaths(captured, current, delimiter = ":") {
183
+ const seen = new Set();
184
+ const out = [];
185
+ for (const entry of [...String(current ?? "").split(delimiter), ...String(captured ?? "").split(delimiter)]) {
186
+ if (entry === "" || seen.has(entry)) continue;
187
+ seen.add(entry);
188
+ out.push(entry);
189
+ }
190
+ return out.join(delimiter);
191
+ }
192
+
193
+ /** THE OTHER WAY A DAEMON FILE CAN BE WRONG - not a stale PATH but a stale
194
+ * SCRIPT (brief 31 repair, 2026-09-12). His Mac's plist named
195
+ * `~/.npm/_npx/<hash>/node_modules/glytch/glytch.mjs`, a 0.5.0 package, while
196
+ * `viewable init` ran from a 0.7.0 checkout - `doctor`'s daemonVersion row
197
+ * (lib/doctor.mjs) already noticed and said "run viewable init", but init's own
198
+ * sync only ever compared the captured PATH and reported "present" without
199
+ * looking at the script path at all. This is that missing look.
200
+ *
201
+ * Compared on the RESOLVED path - `realpath`, so an npm-linked symlink
202
+ * equals the checkout it points at (a plain string compare would rewrite a
203
+ * perfectly good `npm link` on every run) - with the package version beside
204
+ * each script as a second signal, for the same reason `daemonVersion` reads
205
+ * both: a path can survive a rename that a version catches, and a version
206
+ * can survive a path a symlink makes look different.
207
+ *
208
+ * NEVER RED FOR A FACT ABOUT THE WORLD (the rule doctor.mjs's own
209
+ * daemonVersion row already follows): when the INSTALLED script path does
210
+ * not resolve to a real file, there is nothing here to compare it against -
211
+ * `daemonPackageVersion` already calls that "unknown" rather than a mismatch,
212
+ * and so does this. Returns null for "leave it alone", or the two sides of
213
+ * the story for the row to print. */
214
+ async function scriptMismatch({ installedScriptPath, currentScriptPath, currentVersion }) {
215
+ if (!installedScriptPath || installedScriptPath === currentScriptPath) return null;
216
+ let oldReal;
217
+ try {
218
+ oldReal = await realpath(installedScriptPath);
219
+ } catch {
220
+ return null;
221
+ }
222
+ const newReal = await realpath(currentScriptPath).catch(() => null);
223
+ const oldVersion = await daemonPackageVersion(installedScriptPath);
224
+ const pathDiffers = newReal === null || oldReal !== newReal;
225
+ const versionDiffers = oldVersion !== null && oldVersion !== currentVersion;
226
+ if (!pathDiffers && !versionDiffers) return null;
227
+ return { oldPath: installedScriptPath, newPath: currentScriptPath, oldVersion, newVersion: currentVersion };
228
+ }
229
+
230
+ /** The login daemon and the PATH it captured. Machine-wide rather than
231
+ * per-folder, but it is checked here because it is the fourth thing a folder
232
+ * needs and a person asking `doctor` in a folder wants one list.
233
+ *
234
+ * The name stayed `syncLaunchAgent` through brief 21 because every caller and
235
+ * every test says it; WHAT it syncs is whatever lib/logindaemon.mjs says this
236
+ * machine installs. */
237
+ export async function syncLaunchAgent({
238
+ env = process.env,
239
+ // `launchctl` is what brief 19's callers and tests pass; `exec` is the same
240
+ // injected OS tool under the name the interface uses. Either works, and
241
+ // whichever is given is the only thing that ever reaches launchctl,
242
+ // schtasks or systemctl.
243
+ launchctl,
244
+ exec,
245
+ write = true,
246
+ path = env.PATH ?? "",
247
+ nodePath,
248
+ scriptPath,
249
+ // THE ONE CALLER THAT MUST NEVER LOAD ITSELF. multiwatch.mjs's own start
250
+ // (2026-09-06 finding) runs this same sync, and it IS the loaded daemon
251
+ // already - calling `launchctl load` from inside the process launchd is
252
+ // holding would unload the very process running this line, mid-sync. The
253
+ // same is true word for word of `schtasks /run` and of `systemctl --user
254
+ // enable --now` (2026-09-07), so the guard covers all three. That caller
255
+ // passes daemon:true and the present-but-unloaded branch below never
256
+ // reaches the OS tool at all when it is set. init and doctor leave it
257
+ // false, which is what lets THEM restart a stopped daemon.
258
+ daemon = false,
259
+ restart = false,
260
+ ensureVersion = false,
261
+ platform,
262
+ procVersion,
263
+ impl = loginDaemon({ env, platform: platform ?? process.platform, procVersion }),
264
+ // What THIS install would name the row's script-mismatch check against -
265
+ // the running package's own version, unless a test injects one so it
266
+ // never depends on which version this checkout happens to be at.
267
+ version = packageVersion(),
268
+ } = {}) {
269
+ // GLYTCH_NO_DAEMON_INSTALL (GLYTCH_NO_LAUNCHCTL is its older name and still
270
+ // works): the file is written and nothing is registered, and the row says
271
+ // so in those words rather than failing. Ruling 7 of brief 19 - the real
272
+ // agent goes on his Mac after the build, by his word at that moment - and
273
+ // the tests, which must never touch launchd, Task Scheduler or systemd.
274
+ const escape = noDaemonInstall(env);
275
+ const skipped = Boolean(escape);
276
+ const tool = exec ?? launchctl;
277
+ const call = tool ? { exec: tool } : {};
278
+ const label = impl.rowLabel;
279
+ const file = impl.filePath(env);
280
+ const text = await impl.read(env);
281
+ if (text === null) {
282
+ if (!write) return row("plist", label, false, "missing", `no ${file}`);
283
+ const result = await impl.install({ env, ...call, path, nodePath, scriptPath });
284
+ if (skipped) {
285
+ return row("plist", label, true, "written, not loaded", `wrote ${result.file}; ${escape} is set, so ${impl.tool} was not run`);
286
+ }
287
+ // Ruling 6C: an older WSL with no `systemctl --user` is told the truth and
288
+ // given the fallback, rather than sent to `glytch init` for ever.
289
+ if (result.unavailable) {
290
+ return row("plist", label, true, "no systemd here", `wrote ${result.file}; systemctl --user is not available - run: ${RUNNER_BY_HAND}`);
291
+ }
292
+ return row(
293
+ "plist",
294
+ label,
295
+ result.loaded,
296
+ // Not "written, not loaded" - that state is the escape above, which is
297
+ // a green row on purpose. This one failed.
298
+ result.loaded ? "installed" : "written, not registered",
299
+ result.loaded ? `wrote and loaded ${result.file}` : `wrote ${result.file} but ${impl.tool} failed: ${result.why}`,
300
+ result.action,
301
+ );
302
+ }
303
+ const captured = impl.capturedPath(text);
304
+ const missing = missingPathEntries(captured, path, impl.pathDelimiter);
305
+ // Init must compare the loaded process with the installed CLI, not the
306
+ // package.json that npm may already have replaced at this same path.
307
+ // A legacy process has no version proof and needs one replacement. A
308
+ // process that has attested this version is left alone, even when its next
309
+ // beat is delayed: init waits for health rather than disrupting a busy run.
310
+ const loadedState = ensureVersion && !daemon && !skipped
311
+ ? await impl.loadedState({ env, ...call }) : null;
312
+ const health = loadedState?.loaded ? readRunnerHealth(env) : null;
313
+ const replaceLoadedVersion = loadedState?.loaded && (!health?.instance || health.version !== version);
314
+
315
+ // THE FILE NAMES A DIFFERENT SCRIPT THAN THIS INSTALL. Checked before the
316
+ // PATH branches below and gated on `daemon` the same way the present-but-
317
+ // unloaded branch is: the running daemon's own multiwatch loop must never
318
+ // unload itself mid-sync, so it leaves this repair to `init` and `doctor`,
319
+ // which both pass daemon:false.
320
+ if (!daemon) {
321
+ const currentScriptPath = scriptPath ?? viewableScriptPath();
322
+ const mismatch = await scriptMismatch({
323
+ installedScriptPath: impl.programScriptPath(text),
324
+ currentScriptPath,
325
+ currentVersion: version,
326
+ });
327
+ if (mismatch || restart || replaceLoadedVersion) {
328
+ const change = mismatch ?? { oldPath: impl.programScriptPath(text), newPath: currentScriptPath, oldVersion: health?.version ?? null, newVersion: version };
329
+ const oldVersionText = change.oldVersion ?? "unknown";
330
+ if (!write) {
331
+ return row(
332
+ "plist",
333
+ label,
334
+ false,
335
+ "different script",
336
+ `${file} runs ${change.oldPath} (${oldVersionText}); this install is ${change.newPath} (${change.newVersion})`,
337
+ );
338
+ }
339
+ const merged = mergePaths(captured, path, impl.pathDelimiter);
340
+ const result = await impl.install({ env, ...call, path: merged, nodePath, scriptPath: currentScriptPath, force: true });
341
+ if (skipped) {
342
+ return row(
343
+ "plist",
344
+ label,
345
+ true,
346
+ "rewritten, not loaded",
347
+ `rewrote ${result.file}; ${escape} is set, so ${impl.tool} was not run`,
348
+ );
349
+ }
350
+ if (result.unavailable) {
351
+ return row("plist", label, true, "no systemd here", `rewrote ${result.file}; systemctl --user is not available - run: ${RUNNER_BY_HAND}`);
352
+ }
353
+ return row(
354
+ "plist",
355
+ label,
356
+ result.loaded,
357
+ result.loaded ? "rewritten" : "rewritten, not restarted",
358
+ result.loaded
359
+ ? `was running ${change.oldPath} (${oldVersionText}); now ${change.newPath} (${change.newVersion})`
360
+ : `rewrote ${file} but ${impl.tool} failed: ${result.why}`,
361
+ result.action,
362
+ );
363
+ }
364
+ }
365
+ if (captured !== null && captured !== "" && missing.length === 0) {
366
+ // THE FILE IS UNCHANGED, BUT IS THE OS ACTUALLY HOLDING IT (2026-09-06
367
+ // end-to-end finding): `glytch stop` stops the daemon and leaves the file
368
+ // exactly as it was, and this branch used to stop right here and call that
369
+ // "present" even though nothing was watching - so `glytch init` after
370
+ // `glytch stop` printed ok and started nothing. Ask the OS.
371
+ if (skipped || daemon) {
372
+ return row("plist", label, true, "present", `${file}, PATH captured`);
373
+ }
374
+ const state = loadedState ?? await impl.loadedState({ env, ...call });
375
+ if (state.loaded) return row("plist", label, true, "present", `${file}, PATH captured`);
376
+ // REFUSED IS NOT MISSING (2026-09-11, his Windows machine). `schtasks
377
+ // /query` denied and `schtasks /query` on a task that is not there both
378
+ // exit non-zero, and treating the first as the second made init re-run a
379
+ // registration it was never allowed to make, then tell him to run init.
380
+ // The row says what happened and carries the step that actually helps.
381
+ if (state.denied) {
382
+ return row("plist", label, false, "present, cannot be checked", `${file} is on disk; ${impl.tool} refused: ${state.why}`, state.action);
383
+ }
384
+ if (state.unavailable) {
385
+ return row("plist", label, true, "no systemd here", `${file} is on disk; systemctl --user is not available - run: ${RUNNER_BY_HAND}`);
386
+ }
387
+ if (!write) {
388
+ return row(
389
+ "plist",
390
+ label,
391
+ false,
392
+ "present, not loaded",
393
+ `${file} is on disk but ${impl.holder} is not holding ${DAEMON_LABEL} - run: viewable init`,
394
+ );
395
+ }
396
+ const result = await impl.install({ env, ...call, path, nodePath, scriptPath, load: true });
397
+ return row(
398
+ "plist",
399
+ label,
400
+ result.loaded,
401
+ // The state told the truth only when the load worked: "loaded now" on a
402
+ // row already printing FAIL is the line that has to be read twice.
403
+ result.loaded ? "present, was not loaded, loaded now" : "present, not loaded",
404
+ result.loaded
405
+ ? `${file} was on disk but ${impl.holder} was not holding it; loaded now`
406
+ : `${label} present but ${impl.tool} failed: ${result.why}`,
407
+ result.action,
408
+ );
409
+ }
410
+ const merged = mergePaths(captured, path, impl.pathDelimiter);
411
+ if (!write) {
412
+ return row("plist", label, false, "stale PATH", "the captured PATH is missing entries this shell has");
413
+ }
414
+ const result = await impl.install({ env, ...call, path: merged, nodePath, scriptPath, force: true });
415
+ if (skipped) {
416
+ return row("plist", label, true, "PATH refreshed, not loaded", `rewrote ${result.file}; ${escape} is set, so ${impl.tool} was not run`);
417
+ }
418
+ if (result.unavailable) {
419
+ return row("plist", label, true, "no systemd here", `rewrote ${result.file}; systemctl --user is not available - run: ${RUNNER_BY_HAND}`);
420
+ }
421
+ return row(
422
+ "plist",
423
+ label,
424
+ result.loaded,
425
+ // "not loaded" is the escape's state a few lines up, and that row is green
426
+ // on purpose; this one failed, so it gets its own words.
427
+ result.loaded ? "PATH refreshed" : "PATH refreshed, not restarted",
428
+ result.loaded
429
+ ? `the captured PATH was missing ${missing.length} entry/entries this shell has; they were added and your computer restarted`
430
+ : `rewrote ${file} but ${impl.tool} failed: ${result.why}`,
431
+ result.action,
432
+ );
433
+ }
434
+
435
+ /** All four, for one folder. `write:false` turns the whole thing into a
436
+ * report, which is what a caller wants when it is only describing state. */
437
+ export async function syncFolder(
438
+ root,
439
+ {
440
+ env = process.env,
441
+ exec,
442
+ launchctl,
443
+ agents,
444
+ selectedAgents,
445
+ write = true,
446
+ path = env.PATH ?? "",
447
+ nodePath,
448
+ scriptPath,
449
+ daemon = false,
450
+ ensureVersion = false,
451
+ deferRegistry = false,
452
+ platform,
453
+ procVersion,
454
+ impl,
455
+ version,
456
+ } = {},
457
+ ) {
458
+ // Missing choices retain legacy repairs. Once init saves a selection,
459
+ // every caller (including the background daemon) obeys that project choice.
460
+ // AN EXPLICIT EMPTY SELECTION (2026-09-19, `chooseAgents`' own `--yes`
461
+ // fallback when nothing was found) is a deliberate "no agent to connect
462
+ // yet", not the invalid input `parseAgentSelection` refuses - it is passed
463
+ // through as `[]` rather than validated, so the loop below simply writes
464
+ // no agent-specific config while the registry and daemon rows still run.
465
+ const selected = selectedAgents === undefined
466
+ ? await readAgentSelection(root)
467
+ : Array.isArray(selectedAgents) && selectedAgents.length === 0
468
+ ? []
469
+ : parseAgentSelection(selectedAgents);
470
+ const found = agents ?? (await detectAgents({ env }));
471
+ const rows = deferRegistry ? [] : [await syncRegistry(root, { env, write })];
472
+ if (selected === null) {
473
+ rows.push(await syncMcpJson(root, { write, platform, env }));
474
+ rows.push(await syncOpencode(root, { exec, found: found?.opencode?.found === true, write }));
475
+ } else {
476
+ for (const agent of selected) {
477
+ rows.push(agent === "claude"
478
+ ? await syncMcpJson(root, { write, platform, env })
479
+ : await syncProjectMcp(root, agent, { write, env }));
480
+ }
481
+ }
482
+ rows.push(await syncLaunchAgent({ env, launchctl, write, path, nodePath, scriptPath, daemon, ensureVersion, platform, procVersion, impl, version }));
483
+ return rows;
484
+ }