viewable-one 0.0.0-stage → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// ONE LIST, ONE ROW EACH, and a green run means "a session filed from the
|
|
2
|
+
// board in this folder will actually start".
|
|
3
|
+
//
|
|
4
|
+
// Everything a person could otherwise only learn by running a session and
|
|
5
|
+
// watching it fail: is this folder registered, is the daemon loaded, is it
|
|
6
|
+
// beating, is the board sign-in one the board will still accept, does this
|
|
7
|
+
// machine have an engine, is Claude Code logged in, is there a newer version.
|
|
8
|
+
// Six questions plus the four the sync already answers (ruling 5: "doctor
|
|
9
|
+
// lists each as a row").
|
|
10
|
+
//
|
|
11
|
+
// "IS THE APP WRITING THE MIRROR" WAS THE FIFTH, and it is retired 2026-09-13
|
|
12
|
+
// along with the row that asked it - the mirror has not existed since
|
|
13
|
+
// 2026-09-11. The count is unchanged because the sign-in row stopped being a
|
|
14
|
+
// file-exists check on the same day and became a real one. See both dated
|
|
15
|
+
// notes below.
|
|
16
|
+
//
|
|
17
|
+
// EXIT 0 WHEN ALL GREEN, 1 OTHERWISE, because a doctor whose exit code says
|
|
18
|
+
// nothing cannot be put in front of anything.
|
|
19
|
+
//
|
|
20
|
+
// A ROW IS NEVER RED FOR A FACT ABOUT THE WORLD rather than about this
|
|
21
|
+
// install. Offline is "unknown", not a failure - a machine on a plane is not
|
|
22
|
+
// broken. An engine that is not installed is only red when NO engine is.
|
|
23
|
+
//
|
|
24
|
+
// TWO ROWS JOINED THE LIST ON 2026-09-07 (brief 21, session 4).
|
|
25
|
+
//
|
|
26
|
+
// The FIRST row now names the platform, per ruling 6C: "macOS", "Windows" or
|
|
27
|
+
// "WSL (Linux)". It is there because every row under it means something
|
|
28
|
+
// slightly different depending on the answer - "login agent" is a LaunchAgent
|
|
29
|
+
// on one machine, a scheduled task on the next - and because "it works in
|
|
30
|
+
// WSL" being mistaken for "it works on Windows" is the exact confusion the
|
|
31
|
+
// ruling exists to end.
|
|
32
|
+
//
|
|
33
|
+
// The GIT row is a WARNING and not a FAIL, per ruling 8B. Without git there
|
|
34
|
+
// is no worktree, so `makeWorktreeImpl` already falls back to running the
|
|
35
|
+
// session in the linked folder itself - unisolated, which is a real cost and
|
|
36
|
+
// not a broken install. On Windows that also means no Bash tool, since Claude
|
|
37
|
+
// Code runs Bash calls through Git Bash. So the row says what is lost and
|
|
38
|
+
// doctor still exits 0: a person who has decided to run unisolated should not
|
|
39
|
+
// have to read a red list for ever.
|
|
40
|
+
|
|
41
|
+
import { row, warnRow } from "./cli-output.mjs";
|
|
42
|
+
import { PROBE_BINS, detectAgents, execProbe, versionFrom } from "./detect.mjs";
|
|
43
|
+
import { DAEMON_LABEL, daemonPackageVersion, daemonScriptPath, loginDaemon, noDaemonInstall } from "./logindaemon.mjs";
|
|
44
|
+
import { RUNNER_PUSH_STALE_MS } from "./spawn.mjs";
|
|
45
|
+
import { projectMcpServerKey } from "./project-mcp.mjs";
|
|
46
|
+
import { syncFolder } from "./sync.mjs";
|
|
47
|
+
import { readAgentSelection } from "./agent-selection.mjs";
|
|
48
|
+
import { compareVersions, latestFromNpm, packageVersion, verifyInstalledCli } from "./update.mjs";
|
|
49
|
+
import { PERSONAL_TOKEN_REJECTED_HINT, REAUTH_REQUIRED_HINT, hostedStatus } from "./auth.mjs";
|
|
50
|
+
import { presence as hostedPresence } from "./runnerApi.mjs";
|
|
51
|
+
|
|
52
|
+
// `row` is imported from cli-output.mjs (brief 31 review, 2026-09-12 - it
|
|
53
|
+
// used to be a local copy, byte-identical to lib/uninstall.mjs's own and
|
|
54
|
+
// lib/uninstall-project.mjs's own). `action` is the same escape hatch
|
|
55
|
+
// sync.mjs's own `row()` carries (2026-09-11): the generic "run glytch init"
|
|
56
|
+
// is wrong exactly when init is the thing that already failed, or fixes
|
|
57
|
+
// nothing at all - "not signed in to the board" needs `glytch login`, not
|
|
58
|
+
// `glytch init`, and printing the default there is the same loop ruling 6C
|
|
59
|
+
// named for Task Scheduler.
|
|
60
|
+
|
|
61
|
+
/** The one-line step that turns a WRITTEN board entry into an AUTHENTICATED
|
|
62
|
+
* one (brief 30, 2026-09-11). Writing `.mcp.json` / `.codex/config.toml` /
|
|
63
|
+
* `opencode.json` is this package's job; the OAuth handshake against
|
|
64
|
+
* `https://app.viewable.one/mcp` is each agent's own, dynamic client
|
|
65
|
+
* registration meaning there is no shared secret to write into a config file
|
|
66
|
+
* for it - so doctor names the step rather than performing it.
|
|
67
|
+
*
|
|
68
|
+
* VERIFIED AGAINST THE BINARIES INSTALLED ON THIS MACHINE, 2026-09-11:
|
|
69
|
+
* `codex mcp login <name>` (`codex mcp login --help`, Codex CLI) and
|
|
70
|
+
* `opencode mcp auth <name>` (`opencode mcp auth --help`, opencode 1.18.23)
|
|
71
|
+
* are both real subcommands that name the server.
|
|
72
|
+
*
|
|
73
|
+
* CLAUDE CODE'S LINE WAS MARKED UNVERIFIED HERE UNTIL 2026-09-12 (brief 31,
|
|
74
|
+
* Move 2), and the mark was true when it was written: on 2.1.34 `claude mcp
|
|
75
|
+
* --help` listed only `add`/`add-json`/`get`/`list`/`remove`/`serve`, no
|
|
76
|
+
* login verb, so `/mcp` inside the interactive TUI was the only path and all
|
|
77
|
+
* this file could do was repeat the docs. `claude mcp login <name>` exists
|
|
78
|
+
* from 2.1.186, and it was RUN on 2.1.269 against the real endpoint from
|
|
79
|
+
* outside a session: "Authenticated with "board". Its tools are now available
|
|
80
|
+
* in Claude Code." So the line names the verb and its version floor, and
|
|
81
|
+
* keeps `/mcp` as the fallback for an older install - `viewable init` now runs
|
|
82
|
+
* the verb itself when the floor is met (lib/init-connect.mjs). */
|
|
83
|
+
// Keyed by the ROW's key, not the agent name: Claude Code's row is always
|
|
84
|
+
// "mcp" (lib/sync.mjs's syncMcpJson names it that regardless of how it was
|
|
85
|
+
// selected), and "codex"/"opencode" already match the agent name because
|
|
86
|
+
// syncProjectMcp and the legacy syncOpencode both key their row on it.
|
|
87
|
+
// RENAMED 2026-09-12 (brief 31, Move 3, ruling 3): the server key each
|
|
88
|
+
// verb names is `glytch`, not `board` - Claude Code's own consent page then
|
|
89
|
+
// registers the client as "Claude Code (glytch)" (Move 4), matching what
|
|
90
|
+
// `.mcp.json`, `.codex/config.toml` and `opencode.json` now all call it.
|
|
91
|
+
export const BOARD_SIGN_IN = {
|
|
92
|
+
mcp: "Sign in: claude mcp login viewable, on Claude Code 2.1.186 or newer. Below that floor there is no shell verb: open a session on this project, run /mcp, and authenticate the board connection.",
|
|
93
|
+
codex: "Sign in: codex mcp login viewable",
|
|
94
|
+
opencode: "Sign in: opencode mcp auth viewable",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Append the sign-in step to a row that named a board entry, but only once
|
|
98
|
+
* the file itself is in a state worth signing in to - never on "unreadable"
|
|
99
|
+
* or a `write:false` "missing"/"legacy-command" report, where there is
|
|
100
|
+
* nothing on disk yet for the OAuth flow to reach. */
|
|
101
|
+
async function withSignIn(rows, root) {
|
|
102
|
+
for (const r of rows) {
|
|
103
|
+
if (r.ok && Object.hasOwn(BOARD_SIGN_IN, r.key)) {
|
|
104
|
+
const key = await projectMcpServerKey(root, r.key === "mcp" ? "claude" : r.key);
|
|
105
|
+
const hint = BOARD_SIGN_IN[r.key].replace("viewable", key);
|
|
106
|
+
r.detail = r.detail ? `${r.detail} ${hint}` : hint;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return rows;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `warnRow` is imported from cli-output.mjs too (same dedup). Ruling 8B, and
|
|
113
|
+
// the only user of it here is the git row. `allGreen` counts it as green,
|
|
114
|
+
// deliberately.
|
|
115
|
+
|
|
116
|
+
/** "signed in as someone@example.com", or "signed in as account 4db90780..."
|
|
117
|
+
* when the board could name the id but not the address. Null when there is
|
|
118
|
+
* no account to name at all, which leaves the row's old reason standing.
|
|
119
|
+
*
|
|
120
|
+
* The id is SHORTENED to its first segment on purpose: this row is read by a
|
|
121
|
+
* person checking whether the account is the one they think it is, and the
|
|
122
|
+
* first eight characters settle that as well as thirty-six do while still
|
|
123
|
+
* fitting the line. The full id is in the app's own settings if the two ever
|
|
124
|
+
* need comparing character by character. */
|
|
125
|
+
const accountLine = (account) => {
|
|
126
|
+
if (!account) return null;
|
|
127
|
+
if (typeof account.email === "string" && account.email !== "") return `signed in as ${account.email}`;
|
|
128
|
+
if (typeof account.id === "string" && account.id !== "") return `signed in as account ${account.id.split("-")[0]}`;
|
|
129
|
+
return null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const ago = (ms) => {
|
|
133
|
+
const s = Math.round(ms / 1000);
|
|
134
|
+
if (s < 90) return `${s}s ago`;
|
|
135
|
+
const m = Math.round(s / 60);
|
|
136
|
+
if (m < 90) return `${m}m ago`;
|
|
137
|
+
const h = Math.round(m / 60);
|
|
138
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)} days ago`;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/* THE FOLDER-LINKED QUESTION. "Is this folder linked to a board?" is
|
|
142
|
+
* answered by the "daemon beating" row above: `runner_projects` is where a
|
|
143
|
+
* linked folder is recorded, and its "never" case already says `No runner
|
|
144
|
+
* heartbeat for this folder` with the step that fixes it. There is no
|
|
145
|
+
* second thing to check. */
|
|
146
|
+
|
|
147
|
+
/** Every row, in the order they are printed. Everything the outside world
|
|
148
|
+
* answers is injected, so the whole of doctor is testable without a daemon,
|
|
149
|
+
* a network or a launchctl. */
|
|
150
|
+
export async function doctorRows(
|
|
151
|
+
root,
|
|
152
|
+
{
|
|
153
|
+
env = process.env,
|
|
154
|
+
exec,
|
|
155
|
+
launchctl,
|
|
156
|
+
now = Date.now(),
|
|
157
|
+
version = packageVersion(),
|
|
158
|
+
latest = latestFromNpm,
|
|
159
|
+
loaded,
|
|
160
|
+
agents,
|
|
161
|
+
write = true,
|
|
162
|
+
verify = verifyInstalledCli,
|
|
163
|
+
// The machine, and what it installs a login daemon as. Both injected so
|
|
164
|
+
// the suite can print the Windows and WSL lists on this Mac.
|
|
165
|
+
platform = process.platform,
|
|
166
|
+
procVersion,
|
|
167
|
+
impl = loginDaemon({ env, platform, procVersion }),
|
|
168
|
+
// The "daemon beating" row's own network call (brief 31, "doctor asks
|
|
169
|
+
// the board") - injected the same way `latest` above is, so the suite
|
|
170
|
+
// exercises fresh/stale/no-row/offline without a real board-runner or a
|
|
171
|
+
// real fetch. Real default: bridge/lib/runnerApi.mjs's `presence`,
|
|
172
|
+
// reading `runner_projects.last_seen_at` under this row's own bearer.
|
|
173
|
+
presence = hostedPresence,
|
|
174
|
+
fetchImpl,
|
|
175
|
+
} = {},
|
|
176
|
+
) {
|
|
177
|
+
const selected = await readAgentSelection(root);
|
|
178
|
+
const found = agents ?? (await detectAgents({ env, ...(exec ? { exec } : {}) }));
|
|
179
|
+
|
|
180
|
+
// Ruling 6C's row, first, because it says what every row below it means.
|
|
181
|
+
const what = impl.describe();
|
|
182
|
+
const rows = [
|
|
183
|
+
row("platform", "platform", true, what.platformLabel, `your computer runs it as ${what.mechanism} (${what.file})`),
|
|
184
|
+
];
|
|
185
|
+
// Only a fresh terminal can prove the durable entry point. A same-version
|
|
186
|
+
// checkout or temporary copy must never become the login service (#749).
|
|
187
|
+
const service = write && !noDaemonInstall(env)
|
|
188
|
+
? await verify({ env, platform, expectedVersion: version }) : { ok: true };
|
|
189
|
+
const synced = await syncFolder(root, { env, exec, launchctl, agents: found,
|
|
190
|
+
write: write && service.ok, impl, platform, scriptPath: service.scriptPath });
|
|
191
|
+
rows.push(...await withSignIn(synced.map((r) => !service.ok && r.key === "plist"
|
|
192
|
+
? row("plist", impl.rowLabel, false, "not verified", service.error, service.error) : r), root));
|
|
193
|
+
|
|
194
|
+
// Move 5, brief 31: the row above only checks that a plist/launcher/unit
|
|
195
|
+
// is there and loaded - not that it runs the package a person thinks they
|
|
196
|
+
// installed. His Mac's plist pointed at an `~/.npm/_npx` cache running
|
|
197
|
+
// 0.5.0 while this same `doctor` ran from a 0.7.0 checkout, and the row
|
|
198
|
+
// above called the login agent fine because that is all it ever asked. So
|
|
199
|
+
// this row reads the daemon's OWN script path off disk and compares the
|
|
200
|
+
// package beside it to the one answering right now - never red for the
|
|
201
|
+
// FACT that there is no daemon file yet (the row above already says so) or
|
|
202
|
+
// that the path on disk cannot be resolved to a package, per the "never
|
|
203
|
+
// red for a fact about the world" rule at the top of this file.
|
|
204
|
+
const daemonPath = await daemonScriptPath({ env, platform, procVersion, impl });
|
|
205
|
+
if (daemonPath === null) {
|
|
206
|
+
rows.push(row("daemonVersion", "daemon package", true, "unknown", "no daemon file to check yet"));
|
|
207
|
+
} else {
|
|
208
|
+
const daemonVersion = await daemonPackageVersion(daemonPath);
|
|
209
|
+
if (daemonVersion === null) {
|
|
210
|
+
rows.push(row("daemonVersion", "daemon package", true, "unknown", `could not read the package.json beside ${daemonPath}`));
|
|
211
|
+
} else if (daemonVersion === version) {
|
|
212
|
+
rows.push(row("daemonVersion", "daemon package", true, "same", `${version} at ${daemonPath}`));
|
|
213
|
+
} else {
|
|
214
|
+
rows.push(
|
|
215
|
+
row(
|
|
216
|
+
"daemonVersion",
|
|
217
|
+
"daemon package",
|
|
218
|
+
false,
|
|
219
|
+
"mismatch",
|
|
220
|
+
`${daemonVersion} in ${daemonPath}, ${version} here`,
|
|
221
|
+
"run `viewable init` from this install so your computer runs it",
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Read once, here, because the "daemon beating" row below needs it before
|
|
228
|
+
// its own row is pushed (a bearer this doctor already knows is missing is
|
|
229
|
+
// a wasted network call, not a diagnostic) - reused, unchanged, for the
|
|
230
|
+
// "board sign-in" row further down. No network call of its own
|
|
231
|
+
// (hostedStatus's own header): a doctor row read constantly should not
|
|
232
|
+
// cost one just to learn whether there is a credential on file at all.
|
|
233
|
+
const boardAuth = await hostedStatus(env);
|
|
234
|
+
|
|
235
|
+
// The daemon, in two halves that fail differently: the OS holding the
|
|
236
|
+
// registration, and the runner actually writing its mark. Ruling 1A chose
|
|
237
|
+
// Task Scheduler over a Startup shortcut precisely so this half survives on
|
|
238
|
+
// Windows - `schtasks /query` answers it, a shortcut could not.
|
|
239
|
+
const state = loaded === undefined ? await impl.loadedState({ env, ...(launchctl ? { exec: launchctl } : {}) }) : { loaded, unavailable: false, why: "" };
|
|
240
|
+
rows.push(
|
|
241
|
+
state.loaded
|
|
242
|
+
? row("daemon", "daemon loaded", true, "loaded", `${impl.holder} is holding ${DAEMON_LABEL}`)
|
|
243
|
+
: state.unavailable
|
|
244
|
+
? // Ruling 6C: an older WSL with no systemd is told the truth and given
|
|
245
|
+
// the fallback. The "daemon beating" row below is the honest proof
|
|
246
|
+
// either way, which is why this one does not go red over a machine
|
|
247
|
+
// that has no user systemd to install into.
|
|
248
|
+
warnRow("daemon", "daemon loaded", "no systemd here", state.why)
|
|
249
|
+
: row("daemon", "daemon loaded", false, "not loaded", `${impl.holder} does not have ${DAEMON_LABEL}: run viewable init`),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// THE ROW THIS FILE'S FINDING IS ABOUT (brief 31, 2026-09-12, "doctor asks
|
|
253
|
+
// the board"). Before this fix the row read a local mark file
|
|
254
|
+
// (`.glytch/inbox/spawn/.runner.json`, `readMark`) that nothing over rows
|
|
255
|
+
// writes any more - register/heartbeat replaced it with
|
|
256
|
+
// `runner_projects.last_seen_at` the day brief 30 Move 1/2 landed, and this
|
|
257
|
+
// row was simply never updated for that: a 0.7.0 daemon beating the board
|
|
258
|
+
// every few seconds still read FAIL "stopped" here forever, because the
|
|
259
|
+
// file it checked had not moved since an earlier daemon last wrote it. So
|
|
260
|
+
// the row now asks the board directly, through the new read-only
|
|
261
|
+
// `presence` op (bridge/lib/runnerApi.mjs, supabase/functions/board-runner/
|
|
262
|
+
// runner.mjs) - the same fact `runner_projects` already answers the app's
|
|
263
|
+
// own UI with (src/runs/projects.ts), reached the only way this CLI can
|
|
264
|
+
// reach it: over the hosted endpoint under its own bearer, not PostgREST.
|
|
265
|
+
//
|
|
266
|
+
// BOARD SIGN-IN IS CHECKED FIRST so a machine that never ran `glytch
|
|
267
|
+
// login` gets one clear answer ("sign in to see the heartbeat") rather
|
|
268
|
+
// than a network call this doctor already knows will refuse for lack of a
|
|
269
|
+
// bearer - the "board sign-in" row below is where the fix for THAT lives.
|
|
270
|
+
// NEVER RED for either "not signed in" or "offline": both are facts about
|
|
271
|
+
// the world, not this install, the same rule the file's own header states
|
|
272
|
+
// for every other row - a machine on a plane, or one that has not run
|
|
273
|
+
// `glytch login` yet, is not a daemon that stopped beating.
|
|
274
|
+
// WHICH ACCOUNT THE BEARER REACHES, filled from the same presence call the
|
|
275
|
+
// beat row makes (2026-09-14). Null when that call did not happen or did not
|
|
276
|
+
// come back: this must never turn a row red on its own.
|
|
277
|
+
let boardAccount = null;
|
|
278
|
+
if (!boardAuth.signedIn) {
|
|
279
|
+
rows.push(row("beat", "daemon beating", true, "unknown", "sign in to see the heartbeat"));
|
|
280
|
+
} else {
|
|
281
|
+
const presenceResult = await presence({ paths: [root] }, { env, ...(fetchImpl ? { fetchImpl } : {}) });
|
|
282
|
+
if (presenceResult.ok) boardAccount = presenceResult.account ?? null;
|
|
283
|
+
if (!presenceResult.ok) {
|
|
284
|
+
rows.push(row("beat", "daemon beating", true, "unknown", `could not reach the board to check: ${presenceResult.error}`));
|
|
285
|
+
} else {
|
|
286
|
+
// "local" is the exact host value bridge/lib/runner.mjs's
|
|
287
|
+
// registerAndFindProject registers this machine's projects under
|
|
288
|
+
// (`settings.host ?? "local"`) - matched here too, so a folder this
|
|
289
|
+
// account also runs on a different, remote host does not read as
|
|
290
|
+
// "watching" off a stranger's beat.
|
|
291
|
+
const mine = (presenceResult.projects ?? []).find((p) => p.path === root && p.host === "local");
|
|
292
|
+
if (!mine) {
|
|
293
|
+
rows.push(
|
|
294
|
+
row(
|
|
295
|
+
"beat",
|
|
296
|
+
"daemon beating",
|
|
297
|
+
false,
|
|
298
|
+
"never",
|
|
299
|
+
"No runner heartbeat for this folder. Run viewable init from the project folder linked to your board.",
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
} else {
|
|
303
|
+
const age = now - Date.parse(mine.last_seen_at);
|
|
304
|
+
rows.push(
|
|
305
|
+
age <= RUNNER_PUSH_STALE_MS
|
|
306
|
+
? row("beat", "daemon beating", true, "watching", `last beat ${ago(age)}`)
|
|
307
|
+
: row("beat", "daemon beating", false, "stopped", `last beat ${ago(age)}, past the ${RUNNER_PUSH_STALE_MS / 1000}s staleness window`),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The "board mirror" row stood here until 2026-09-13. See the retirement
|
|
314
|
+
// note above mirrorFreshness's old home for what it was and why it is gone.
|
|
315
|
+
|
|
316
|
+
const names = PROBE_BINS.filter((b) => (selected === null || selected.includes(b.key)) && found?.[b.key]?.found).map(
|
|
317
|
+
(b) => `${b.label}${found[b.key].version ? ` ${found[b.key].version}` : ""}`,
|
|
318
|
+
);
|
|
319
|
+
const missing = selected?.filter((key) => !found?.[key]?.found) ?? [];
|
|
320
|
+
rows.push(
|
|
321
|
+
missing.length > 0
|
|
322
|
+
? row("agents", "coding agents", false, "missing", `Selected but not installed: ${missing.join(", ")}. Install them or rerun viewable init to change the selection.`)
|
|
323
|
+
: names.length > 0
|
|
324
|
+
? row("agents", "coding agents", true, "found", names.join(", "))
|
|
325
|
+
: row("agents", "coding agents", false, "none", "no coding agent on PATH: install Claude Code, opencode or Codex"),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
rows.push(
|
|
329
|
+
selected !== null && !selected.includes("claude")
|
|
330
|
+
? row("login", "Claude Code login", true, "n/a", "Claude Code is not selected for this project")
|
|
331
|
+
: found?.claude?.found !== true
|
|
332
|
+
? row("login", "Claude Code login", true, "n/a", "Claude Code is not installed on your computer")
|
|
333
|
+
: found.claude.loggedIn
|
|
334
|
+
// The detail no longer names the file (2026-09-12). It said
|
|
335
|
+
// "~/.claude/.credentials.json is there" and on macOS that file is
|
|
336
|
+
// usually NOT there - the credential is a login-Keychain item, which
|
|
337
|
+
// is what lib/detect.mjs asks for first. A row whose state is right
|
|
338
|
+
// and whose reason is wrong is worse than one that just says the
|
|
339
|
+
// state: it sends someone looking for a file that was never written.
|
|
340
|
+
? row("login", "Claude Code login", true, "logged in", "a stored credential was found: the login Keychain on macOS, ~/.claude/.credentials.json elsewhere")
|
|
341
|
+
: row("login", "Claude Code login", false, "logged out", "run `claude` once and log in, or sessions fail at spawn"),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// ONE ROW (brief 30, W2a): the local stdio MCP server is retired, and
|
|
345
|
+
// every agent reaches the board through the hosted endpoint now
|
|
346
|
+
// (bridge/lib/auth.mjs) - so a run's board tools need this to
|
|
347
|
+
// be true rather than a mirror on disk. No network call: a doctor row read
|
|
348
|
+
// constantly should not cost one, and `hostedToken`'s own refresh already
|
|
349
|
+
// covers the "signed in but the access token has aged" case at call time.
|
|
350
|
+
// `boardAuth` itself was already read above, before the "daemon beating"
|
|
351
|
+
// row, which needs the same answer first.
|
|
352
|
+
//
|
|
353
|
+
// THE CHECK THIS ROW DID NOT HAVE, added 2026-09-13 alongside the backoff
|
|
354
|
+
// in lib/auth.mjs. "Signed in" used to mean no more than "auth.json is
|
|
355
|
+
// there", so a machine whose credential the endpoint had revoked read GREEN
|
|
356
|
+
// on the one row a person checks when their runner cannot authenticate -
|
|
357
|
+
// the file is present, and presence was the whole test. It is the same
|
|
358
|
+
// fault the retired "board mirror" row had in the other direction: a row
|
|
359
|
+
// whose reason is wrong is worse than no row.
|
|
360
|
+
//
|
|
361
|
+
// `hostedStatus` now also reports whether the credential carries a
|
|
362
|
+
// dead-credential mark - written next to auth.json by whichever process met
|
|
363
|
+
// the refusal, usually the daemon, which is why doctor can report it from a
|
|
364
|
+
// different process. STILL NO NETWORK CALL: it is one more small file read,
|
|
365
|
+
// so the bargain the rest of doctor keeps is intact.
|
|
366
|
+
rows.push(
|
|
367
|
+
boardAuth.dead === true
|
|
368
|
+
? boardAuth.via === "personal token"
|
|
369
|
+
// A REJECTED PERSONAL TOKEN READS DIFFERENTLY (2026-09-19, brief 43
|
|
370
|
+
// review, B2). It was never going to expire or refresh - it is
|
|
371
|
+
// revoked from Settings - so "expired and cannot be renewed" would
|
|
372
|
+
// have been the wrong sentence even after the mark started being
|
|
373
|
+
// written for it. "rejected" names what actually happened, and the
|
|
374
|
+
// hint names the actual fix: a fresh token, not `glytch login`.
|
|
375
|
+
? row(
|
|
376
|
+
"boardAuth",
|
|
377
|
+
"board sign-in",
|
|
378
|
+
false,
|
|
379
|
+
"rejected",
|
|
380
|
+
`the personal token in ~/.glytch/auth.json was rejected by the board${boardAuth.deadSince ? ` (since ${boardAuth.deadSince})` : ""}`,
|
|
381
|
+
PERSONAL_TOKEN_REJECTED_HINT,
|
|
382
|
+
)
|
|
383
|
+
: row(
|
|
384
|
+
"boardAuth",
|
|
385
|
+
"board sign-in",
|
|
386
|
+
false,
|
|
387
|
+
"expired",
|
|
388
|
+
`the credential in ~/.glytch/auth.json was refused by the board${boardAuth.deadSince ? ` (since ${boardAuth.deadSince})` : ""} and cannot be renewed`,
|
|
389
|
+
REAUTH_REQUIRED_HINT,
|
|
390
|
+
)
|
|
391
|
+
: boardAuth.signedIn
|
|
392
|
+
? row(
|
|
393
|
+
"boardAuth",
|
|
394
|
+
"board sign-in",
|
|
395
|
+
true,
|
|
396
|
+
"signed in",
|
|
397
|
+
// AND WHO AS (2026-09-14). "Signed in" was the whole reason, and a
|
|
398
|
+
// credential left on a previous account reads exactly like a
|
|
399
|
+
// credential on the right one - which is how two days of session
|
|
400
|
+
// reports landed on a board nobody was looking at. The name comes
|
|
401
|
+
// from the board's `presence` op, which resolved this very token,
|
|
402
|
+
// so it is the endpoint's answer and not a guess off a local file.
|
|
403
|
+
// Missing when the call did not happen or did not come back; the
|
|
404
|
+
// row then says what it always said rather than nothing.
|
|
405
|
+
accountLine(boardAccount) ??
|
|
406
|
+
// `via: "personal token"` (brief 43, 2026-09-19) is VIEWABLE_BOARD_TOKEN
|
|
407
|
+
// persisted to disk rather than read live from the environment -
|
|
408
|
+
// worded like its own case, not folded into the generic
|
|
409
|
+
// "auth.json is there" that only ever meant an OAuth login before.
|
|
410
|
+
(boardAuth.via === "VIEWABLE_BOARD_TOKEN"
|
|
411
|
+
? "VIEWABLE_BOARD_TOKEN is set"
|
|
412
|
+
: boardAuth.via === "personal token"
|
|
413
|
+
? "a personal access token is stored in ~/.glytch/auth.json"
|
|
414
|
+
: "~/.glytch/auth.json is there"),
|
|
415
|
+
)
|
|
416
|
+
: row(
|
|
417
|
+
"boardAuth",
|
|
418
|
+
"board sign-in",
|
|
419
|
+
false,
|
|
420
|
+
"not signed in",
|
|
421
|
+
"run `viewable login`, or set VIEWABLE_BOARD_TOKEN to a personal access token for a script",
|
|
422
|
+
"Run `viewable login`. It opens a browser to sign in and approve, and the credential lands in ~/.glytch/auth.json. Re-running `viewable init` does not do this step.",
|
|
423
|
+
),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// Ruling 8B: no git is a WARNING and sessions run unisolated. On Windows it
|
|
427
|
+
// is Git for Windows, and it carries the Bash tool with it.
|
|
428
|
+
const git = await (exec ?? execProbe)("git", ["--version"]);
|
|
429
|
+
rows.push(
|
|
430
|
+
git?.ok === true
|
|
431
|
+
? row("git", "git", true, "found", versionFrom(git.stdout) ? `git ${versionFrom(git.stdout)}` : "git is on PATH")
|
|
432
|
+
: warnRow(
|
|
433
|
+
"git",
|
|
434
|
+
"git",
|
|
435
|
+
"missing",
|
|
436
|
+
impl.platform === "windows"
|
|
437
|
+
? "no git: sessions run unisolated in the linked folder, and Claude Code's Bash tool needs Git for Windows"
|
|
438
|
+
: "no git: sessions run unisolated in the linked folder, with no worktree of their own",
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const newestPublished = await latest().catch(() => null);
|
|
443
|
+
rows.push(
|
|
444
|
+
newestPublished === null
|
|
445
|
+
? row("version", "version", true, "unknown", `${version} installed; npm did not answer (offline?)`)
|
|
446
|
+
: compareVersions(newestPublished, version) > 0
|
|
447
|
+
? row("version", "version", true, "update available", `${version} installed, ${newestPublished} on npm: npm i -g glytch`)
|
|
448
|
+
: row("version", "version", true, "current", `${version} is the latest`),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
return rows;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Format for a terminal. One row per line, the state in a column, the detail
|
|
455
|
+
* after it. */
|
|
456
|
+
export function formatRows(rows) {
|
|
457
|
+
return rows.map(
|
|
458
|
+
(r) => `${r.warn ? "warn" : r.ok ? "ok " : "FAIL"} ${r.label.padEnd(22)} ${r.state.padEnd(16)} ${r.detail}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A warn row is green (ruling 8B): the install works, and what it costs is
|
|
463
|
+
* printed rather than counted as a failure. */
|
|
464
|
+
export const allGreen = (rows) => rows.every((r) => r.ok);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Exit code classification for child processes. Distinguishes crashes (SIGSEGV,
|
|
2
|
+
// SIGABRT, etc.) from clean exits and other failures, so runRequest can retry
|
|
3
|
+
// crashes that happen before any output streamed.
|
|
4
|
+
|
|
5
|
+
/** 128+N, the shell's own spelling of "died on signal N", which is how a
|
|
6
|
+
* Windows child reports a crash it has no signal to carry. Only the signals
|
|
7
|
+
* the set below already calls a crash are listed, and SIGKILL is deliberately
|
|
8
|
+
* absent: 137 there would be someone else's kill, and the runner's own kill
|
|
9
|
+
* arrives as `isKill` through the signal branch. */
|
|
10
|
+
const WINDOWS_SIGNAL_EXITS = new Map([
|
|
11
|
+
[132, "SIGILL"],
|
|
12
|
+
[133, "SIGTRAP"],
|
|
13
|
+
[134, "SIGABRT"],
|
|
14
|
+
[135, "SIGBUS"],
|
|
15
|
+
[136, "SIGFPE"],
|
|
16
|
+
[139, "SIGSEGV"],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** Classify a child's exit code and signal.
|
|
20
|
+
* Returns { isCrash: boolean, isKill: boolean, description: string }. */
|
|
21
|
+
export function classifyExit(code, signal, platform = process.platform) {
|
|
22
|
+
// SIGKILL is never retried - it means the runner itself killed the child.
|
|
23
|
+
if (signal === "SIGKILL") {
|
|
24
|
+
return { isCrash: false, isKill: true, description: "killed (SIGKILL)" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Unix crash signals
|
|
28
|
+
const crashSignals = new Set(["SIGSEGV", "SIGABRT", "SIGBUS", "SIGILL", "SIGFPE", "SIGTRAP"]);
|
|
29
|
+
if (signal && crashSignals.has(signal)) {
|
|
30
|
+
return { isCrash: true, isKill: false, description: `crash (${signal})` };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Windows crash codes (access violation, stack overflow, etc.)
|
|
34
|
+
// 0xC0000005 = access violation, 0xC00000FD = stack overflow
|
|
35
|
+
const windowsCrashCodes = new Set([0xC0000005, 0xC00000FD, 3221225477, 3221225725]);
|
|
36
|
+
if (typeof code === "number" && windowsCrashCodes.has(code)) {
|
|
37
|
+
const hex = `0x${code.toString(16).toUpperCase()}`;
|
|
38
|
+
return { isCrash: true, isKill: false, description: `crash (${hex})` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A CRASH ON WINDOWS ARRIVES AS A NUMBER, NEVER AS A SIGNAL (2026-09-07,
|
|
42
|
+
// brief 21, the first windows-latest run). Windows has no signals, so a child
|
|
43
|
+
// that aborts - `process.abort()`, an assertion failure, a Node fatal error -
|
|
44
|
+
// closes with `signal === null` and code 128+SIGABRT, the same 128+N
|
|
45
|
+
// convention a POSIX shell reports. The crash-signal set above therefore saw
|
|
46
|
+
// nothing, every abort read as "exit 134", and BOTH halves of the retry
|
|
47
|
+
// ruling were wrong on that platform: a crash before any output was not
|
|
48
|
+
// retried, and a crash after output was reported as a plain non-zero exit
|
|
49
|
+
// rather than as the crash it was. Only win32, because a POSIX child that
|
|
50
|
+
// aborts brings its signal with it and one that exits 134 deliberately means
|
|
51
|
+
// 134.
|
|
52
|
+
if (platform === "win32" && typeof code === "number" && WINDOWS_SIGNAL_EXITS.has(code)) {
|
|
53
|
+
return { isCrash: true, isKill: false, description: `crash (${WINDOWS_SIGNAL_EXITS.get(code)})` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Not a crash
|
|
57
|
+
if (signal) {
|
|
58
|
+
return { isCrash: false, isKill: false, description: signal };
|
|
59
|
+
}
|
|
60
|
+
if (typeof code === "number") {
|
|
61
|
+
return { isCrash: false, isKill: false, description: `exit ${code}` };
|
|
62
|
+
}
|
|
63
|
+
return { isCrash: false, isKill: false, description: "unknown" };
|
|
64
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// THE GLYTCH FOLDER (2026-09-30, the owner's ruling: "like we have .claude we
|
|
2
|
+
// have .glytch by default ... cowork also created projects in Documents").
|
|
3
|
+
//
|
|
4
|
+
// ~/.glytch stays the runner's own, hidden. ~/Glytch is the person's: a
|
|
5
|
+
// visible folder, a git repository, registered like any folder `glytch init`
|
|
6
|
+
// adds. A chat on a board with no folder picked works here, in a subfolder
|
|
7
|
+
// named after the board (`boardFolder` on a chat-turn request, bridge/SPAWN.md),
|
|
8
|
+
// so every board has somewhere to work before anyone has linked a folder.
|
|
9
|
+
//
|
|
10
|
+
// Made when the daemon starts watching, and kept: nothing here removes it.
|
|
11
|
+
|
|
12
|
+
import { lstat, mkdir, realpath, writeFile } from "node:fs/promises";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
15
|
+
import { addFolder } from "./registry.mjs";
|
|
16
|
+
import { git, isChatWorktreePath, resolveChatCwd } from "./worktree.mjs";
|
|
17
|
+
|
|
18
|
+
/** Where the Glytch folder lives, or null when there is none to make.
|
|
19
|
+
*
|
|
20
|
+
* GLYTCH_FOLDER names it, and "off" turns it off. A scratch GLYTCH_HOME (the
|
|
21
|
+
* tests, and every agent that runs glytch for real) gets none unless it names
|
|
22
|
+
* one: a test must never make a folder in the real home. */
|
|
23
|
+
export function glytchFolderPath(env = process.env) {
|
|
24
|
+
const named = (env.VIEWABLE_FOLDER ?? env.GLYTCH_FOLDER)?.trim();
|
|
25
|
+
if (named === "off") return null;
|
|
26
|
+
if (named) return resolve(named);
|
|
27
|
+
if ((env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim()) return null;
|
|
28
|
+
return join(homedir(), "Glytch");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const README =
|
|
32
|
+
"# Viewable\n\n" +
|
|
33
|
+
"Coding agents on your boards work here when a board has no folder of its own.\n" +
|
|
34
|
+
"Each board gets its own subfolder, named after the board.\n\n" +
|
|
35
|
+
"To have an agent work somewhere else, pick another folder in the chat's folder menu.\n";
|
|
36
|
+
|
|
37
|
+
// A first commit needs a name and an email, and a fresh machine may have
|
|
38
|
+
// neither. These apply to this one commit only; signing is off for the same
|
|
39
|
+
// reason (a signing prompt would hang the daemon).
|
|
40
|
+
const COMMITTER = ["-c", "user.name=Viewable", "-c", "user.email=glytch@localhost", "-c", "commit.gpgsign=false"];
|
|
41
|
+
|
|
42
|
+
/** Make the Glytch folder if it is missing, as a git repository with one
|
|
43
|
+
* commit (a chat worktree is cut from a commit), and register it. Returns
|
|
44
|
+
* the path, or null when there is none. Never throws: a daemon that cannot
|
|
45
|
+
* make it still watches every other folder. */
|
|
46
|
+
export async function ensureGlytchFolder(env = process.env, log = () => {}) {
|
|
47
|
+
const path = glytchFolderPath(env);
|
|
48
|
+
if (path === null) return null;
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(path, { recursive: true });
|
|
51
|
+
// Its own repository, not one it happens to sit inside: a home folder
|
|
52
|
+
// kept under git would otherwise lend ~/Glytch its history.
|
|
53
|
+
const top = await git(["rev-parse", "--show-toplevel"], path);
|
|
54
|
+
if (!top.ok || (await realpath(top.stdout.trim())) !== (await realpath(path))) {
|
|
55
|
+
const init = await git(["init"], path);
|
|
56
|
+
if (!init.ok) throw new Error(`git init failed: ${init.stderr?.trim() || "no detail"}`);
|
|
57
|
+
await git(["symbolic-ref", "HEAD", "refs/heads/main"], path);
|
|
58
|
+
}
|
|
59
|
+
const head = await git(["rev-parse", "--verify", "HEAD"], path);
|
|
60
|
+
if (!head.ok) {
|
|
61
|
+
const readme = join(path, "README.md");
|
|
62
|
+
// Never write through a link or over a file already there. "wx" alone
|
|
63
|
+
// is enough on POSIX, but Windows follows a dangling link on create and
|
|
64
|
+
// writes its target, so anything at the name (a link included) is left.
|
|
65
|
+
const taken = await lstat(readme).then(() => true, (error) => { if (error?.code === "ENOENT") return false; throw error; });
|
|
66
|
+
if (!taken) {
|
|
67
|
+
try { await writeFile(readme, README, { flag: "wx" }); } catch (error) { if (error?.code !== "EEXIST") throw error; }
|
|
68
|
+
}
|
|
69
|
+
await git(["add", "README.md"], path);
|
|
70
|
+
const commit = await git([...COMMITTER, "commit", "-m", "Viewable folder"], path);
|
|
71
|
+
if (!commit.ok) throw new Error(`first commit failed: ${commit.stderr?.trim() || "no detail"}`);
|
|
72
|
+
log(`made the Viewable folder at ${path}`);
|
|
73
|
+
}
|
|
74
|
+
await addFolder(path, env);
|
|
75
|
+
return path;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
log(`could not make the Viewable folder at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- THE BOARD'S SUBFOLDER, on a chat turn ----------
|
|
83
|
+
|
|
84
|
+
/** The chat worktree and the board's subfolder a chat turn runs in. A first
|
|
85
|
+
* turn names the tree, and the subfolder is the board's name. Every turn
|
|
86
|
+
* after it hands back the runner's own cwd, the subfolder it ran in, and
|
|
87
|
+
* keeps it: a renamed board's conversation stays with its files. The parent
|
|
88
|
+
* is asked first, so a board named "chat-…" is not read as a tree. */
|
|
89
|
+
export function chatBoardOf(root, cwd, name) {
|
|
90
|
+
return isChatWorktreePath(root, dirname(cwd)) ? { tree: dirname(cwd), name: basename(cwd) } : { tree: cwd, name };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Make the board's subfolder inside the chat worktree and return it, or a
|
|
94
|
+
* refusal in words. Checked again once made: a link planted under that name
|
|
95
|
+
* would lead the agent out of the folder. */
|
|
96
|
+
export async function makeBoardCwd(root, tree, name) {
|
|
97
|
+
const path = join(tree, name);
|
|
98
|
+
try {
|
|
99
|
+
await mkdir(path, { recursive: true });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { ok: false, reason: `Could not make the board's folder ${path}: ${error instanceof Error ? error.message : String(error)}` };
|
|
102
|
+
}
|
|
103
|
+
const checked = resolveChatCwd(root, path);
|
|
104
|
+
return checked.ok ? { ok: true, path } : { ok: false, reason: `The board's folder ${path}: ${checked.reason}` };
|
|
105
|
+
}
|