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
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 viewable 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 `viewable login`, not
|
|
58
|
+
// `viewable 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
|
+
// `viewable 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 `viewable 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 viewable-one@latest --force`)
|
|
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
|
+
}
|
package/lib/hosted.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// THE HOSTED BOARD CLIENT (brief 30, W2a, 2026-09-11). Replaces rowsource.mjs
|
|
2
|
+
// entirely: there is no longer a separate "rows mode" a folder opts into with
|
|
3
|
+
// GLYTCH_BOARD_SOURCE - every agent reaches the board through the hosted
|
|
4
|
+
// endpoint (supabase/functions/board-mcp) whenever there is no local mirror
|
|
5
|
+
// to read instead, and lib/auth.mjs is the one place that resolves a bearer
|
|
6
|
+
// for it (a `glytch login` credential, or VIEWABLE_BOARD_TOKEN for a script).
|
|
7
|
+
//
|
|
8
|
+
// WHY THE ENDPOINT AND NOT POSTGREST DIRECTLY, still: neither a `pmb_`
|
|
9
|
+
// personal access token nor a `pmo_` OAuth token is a Supabase JWT, and
|
|
10
|
+
// PostgREST authenticates JWTs. The Edge Function's own resolve step
|
|
11
|
+
// (supabase/functions/_shared/auth.mjs, shared with board-runner since brief
|
|
12
|
+
// 30 Move 2) is the only thing that turns either shape into a user id, so
|
|
13
|
+
// this file talks to it and nothing else.
|
|
14
|
+
|
|
15
|
+
import { hostedConfig } from "./auth.mjs";
|
|
16
|
+
import { authenticatedJsonPost } from "./authenticatedHttp.mjs";
|
|
17
|
+
import { runnerMcpUrlFrom } from "./mcpjson.mjs";
|
|
18
|
+
export { NOT_SIGNED_IN_HINT } from "./authenticatedHttp.mjs";
|
|
19
|
+
|
|
20
|
+
let nextId = 1;
|
|
21
|
+
|
|
22
|
+
/** Runner-owned calls use the same direct endpoint resolver as run children. */
|
|
23
|
+
export async function callRunnerTool(name, args, options = {}) {
|
|
24
|
+
const env = options.env ?? process.env;
|
|
25
|
+
const config = await hostedConfig({ env });
|
|
26
|
+
return callTool(name, args, { ...options, mcpUrl: runnerMcpUrlFrom(config, env) });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Call one tool on the hosted endpoint and return its text.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} name the tool, e.g. "get_board"
|
|
33
|
+
* @param {object} args
|
|
34
|
+
* @param {{sessionKey?: string, boardId?: string, mcpUrl?: string, env?: object, fetchImpl?: Function, forceRefresh?: boolean}} [options]
|
|
35
|
+
* `sessionKey`, when given, rides as `X-Glytch-Session` - the board-mcp
|
|
36
|
+
* endpoint (2026-09-11) lets it replace the token-derived session key, so a
|
|
37
|
+
* spawned child's tool calls land under the same run key its report(),
|
|
38
|
+
* ask_user and everything else on this run already use.
|
|
39
|
+
* For `get_node`, `boardId` selects a board in the same sequential JSON-RPC
|
|
40
|
+
* batch. Separate requests do not retain the server's board selection. This
|
|
41
|
+
* option is read-only: the server continues a batch after selection errors,
|
|
42
|
+
* so it must never be used for a write. A failed selection refuses the read.
|
|
43
|
+
* @returns {Promise<{text: string, isError: boolean}>}
|
|
44
|
+
*/
|
|
45
|
+
export async function callTool(name, args, options = {}) {
|
|
46
|
+
const { sessionKey, boardId, env = process.env } = options;
|
|
47
|
+
if (boardId !== undefined && (name !== "get_node" || typeof boardId !== "string" || boardId.trim() === "")) {
|
|
48
|
+
return { text: "A board-scoped read requires get_node and a non-empty boardId.", isError: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Resolve before fetching so transport failures name the actual endpoint.
|
|
52
|
+
// This override only affects the MCP POST, never OAuth refresh requests.
|
|
53
|
+
const mcpUrl = options.mcpUrl ?? (await hostedConfig({ env })).mcpUrl;
|
|
54
|
+
const request = { jsonrpc: "2.0", id: nextId++, method: "tools/call", params: { name, arguments: args ?? {} } };
|
|
55
|
+
const select = boardId === undefined ? null : {
|
|
56
|
+
jsonrpc: "2.0", id: nextId++, method: "tools/call",
|
|
57
|
+
params: { name: "get_board", arguments: { board_id: boardId, limit: 1 } },
|
|
58
|
+
};
|
|
59
|
+
const headers = typeof sessionKey === "string" && sessionKey.trim() !== "" ? { "X-Glytch-Session": sessionKey.trim() } : {};
|
|
60
|
+
let result;
|
|
61
|
+
try {
|
|
62
|
+
result = await authenticatedJsonPost(mcpUrl, select ? [select, request] : request, { ...options, headers });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { text: `Could not reach ${mcpUrl}: ${error instanceof Error ? error.message : String(error)}`, isError: true };
|
|
65
|
+
}
|
|
66
|
+
if (result.authError) return { text: result.authError, isError: true };
|
|
67
|
+
const { response, text } = result;
|
|
68
|
+
let { payload } = result;
|
|
69
|
+
if (select && response.ok) {
|
|
70
|
+
const selected = Array.isArray(payload) ? payload.find((part) => part?.id === select.id) : null;
|
|
71
|
+
const answer = Array.isArray(payload) ? payload.find((part) => part?.id === request.id) : null;
|
|
72
|
+
// The endpoint may execute the read after a failed selection. Never
|
|
73
|
+
// mistake its default-board answer for the board the caller requested.
|
|
74
|
+
if (selected?.error || selected?.result?.isError === true) payload = selected;
|
|
75
|
+
else if (!selected?.result || !answer) payload = { error: { message: "The board endpoint returned an incomplete board-scoped response." } };
|
|
76
|
+
else payload = answer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (payload?.error) {
|
|
80
|
+
const extra =
|
|
81
|
+
response.status === 401
|
|
82
|
+
? " Sign in again with `viewable login`, or mint a new personal access token and set VIEWABLE_BOARD_TOKEN."
|
|
83
|
+
: "";
|
|
84
|
+
return { text: `${typeof payload.error === "string" ? payload.error : payload.error.message ?? "The board endpoint refused the request."}${extra}`, isError: true };
|
|
85
|
+
}
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
return {
|
|
88
|
+
text: `The board endpoint answered ${response.status}: ${text.slice(0, 400)}`,
|
|
89
|
+
isError: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(payload?.result?.content)) {
|
|
93
|
+
return { text: "The board endpoint returned an incomplete or unreadable JSON response.", isError: true };
|
|
94
|
+
}
|
|
95
|
+
const content = payload.result.content;
|
|
96
|
+
const joined = content
|
|
97
|
+
.filter((part) => part?.type === "text")
|
|
98
|
+
.map((part) => part.text)
|
|
99
|
+
.join("\n");
|
|
100
|
+
// IMAGE BLOCKS, ALONGSIDE THE TEXT (2026-09-11, brief 30 Move 2b, item 6).
|
|
101
|
+
// No tool on this endpoint sends any today - `collect_session`'s own reply
|
|
102
|
+
// is text-only, and the `session_messages.images` column this would carry
|
|
103
|
+
// is another worker's change, not yet landed here - but the shape is data
|
|
104
|
+
// the wire format (an MCP content array) already allows, so this is
|
|
105
|
+
// forward-compatible plumbing rather than a guess: the day a tool DOES
|
|
106
|
+
// send an image block, every caller of `callTool` picks it up with no
|
|
107
|
+
// change on its own side.
|
|
108
|
+
const images = content
|
|
109
|
+
.filter((part) => part?.type === "image" && typeof part.data === "string")
|
|
110
|
+
.map((part) => ({ mediaType: part.mimeType ?? part.mediaType ?? "image/png", data: part.data }));
|
|
111
|
+
return { text: joined, isError: payload?.result?.isError === true, ...(images.length > 0 ? { images } : {}) };
|
|
112
|
+
}
|