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
@@ -0,0 +1,75 @@
1
+ // Shared authenticated JSON transport for the MCP and runner endpoints.
2
+ // The deadline covers token refresh and response-body consumption, not just
3
+ // receiving headers. A stalled endpoint must not wedge runner settlement.
4
+ import { REAUTH_REQUIRED_HINT, hostedToken, noteCredentialRejected, reauthRequired } from './auth.mjs';
5
+
6
+ export const NOT_SIGNED_IN_HINT =
7
+ 'Not signed in to the hosted board. Run `viewable login`, or for a script set ' +
8
+ 'VIEWABLE_BOARD_TOKEN to a personal access token minted in Viewable ' +
9
+ '(Settings, Connection, Access tokens).';
10
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
11
+
12
+ export async function authenticatedJsonPost(url, body, options = {}) {
13
+ const { env = process.env, fetchImpl = fetch, forceRefresh = false, headers = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, signal } = options;
14
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('Request timeout must be a positive number.');
15
+ const controller = new AbortController();
16
+ let timer;
17
+ let onAbort;
18
+ const deadline = new Promise((_, reject) => {
19
+ onAbort = () => {
20
+ controller.abort(signal.reason);
21
+ reject(signal.reason);
22
+ };
23
+ signal?.addEventListener('abort', onAbort, { once: true });
24
+ if (signal?.aborted) onAbort();
25
+ timer = setTimeout(() => {
26
+ const error = new Error(`Request timed out after ${timeoutMs}ms`);
27
+ controller.abort(error);
28
+ reject(error);
29
+ }, timeoutMs);
30
+ });
31
+ const boundedFetch = (target, init = {}) => {
32
+ if (controller.signal.aborted) {
33
+ // Only this pre-dispatch boundary can prove a rotating token was not sent.
34
+ throw Object.assign(new Error('Request canceled before fetch.'), { requestNotSent: true });
35
+ }
36
+ return fetchImpl(target, { ...init, signal: init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal });
37
+ };
38
+ const post = async bearer => {
39
+ const response = await boundedFetch(url, {
40
+ method: 'POST',
41
+ headers: { ...headers, Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json', Accept: 'application/json' },
42
+ body: JSON.stringify(body),
43
+ });
44
+ const text = await response.text();
45
+ let payload = null;
46
+ try { payload = JSON.parse(text); } catch { /* Callers report their protocol's malformed-response error. */ }
47
+ return { response, text, payload };
48
+ };
49
+ const execute = async () => {
50
+ controller.signal.throwIfAborted();
51
+ // The shared refresh owns its finite deadline. This caller only stops
52
+ // waiting; it must not cancel another caller's rotating-token exchange.
53
+ const token = await hostedToken({ env, fetchImpl, forceRefresh });
54
+ controller.signal.throwIfAborted();
55
+ if (!token) return { authError: await reauthRequired(env) ? REAUTH_REQUIRED_HINT : NOT_SIGNED_IN_HINT };
56
+ let result = await post(token);
57
+ let lastBearer = token;
58
+ if (result.response.status === 401 && !forceRefresh) {
59
+ controller.signal.throwIfAborted();
60
+ const refreshed = await hostedToken({ env, fetchImpl, forceRefresh: true });
61
+ controller.signal.throwIfAborted();
62
+ if (refreshed && refreshed !== token) {
63
+ // A failed retry is a transport failure, not proof that the fresh
64
+ // credential was rejected. Never mark it dead based on the old 401.
65
+ result = await post(refreshed);
66
+ lastBearer = refreshed;
67
+ }
68
+ }
69
+ controller.signal.throwIfAborted();
70
+ if (result.response.status === 401) await noteCredentialRejected({ env, token: lastBearer });
71
+ return result;
72
+ };
73
+ try { return await Promise.race([execute(), deadline]); }
74
+ finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); }
75
+ }
@@ -0,0 +1,225 @@
1
+ // TELLING THE BOARD ABOUT A SESSION, over rows (brief 30 Move 2, 2026-09-11).
2
+ //
3
+ // What is NOT retired: telling the board what happened WHILE a session runs
4
+ // or after it ends - a note on the run's own idea, and a question note when
5
+ // something needs a human ruling (a failed merge). Those still go through
6
+ // the SAME board API vocabulary (INBOX_OPS.md's `addComment`/`addQuestion`),
7
+ // just over `hosted.callTool`'s `propose`/`ask_user` instead of an ops file,
8
+ // under the run's own session key (`X-Glytch-Session`) so they read as this
9
+ // run's own words rather than the runner's OAuth identity.
10
+ //
11
+ // `askedAndNeverReported`/`silenceNote` are gone, and that is a real
12
+ // loss rather than a rename: they read `inbox/ops/*.json` (via
13
+ // opsFilesForKey) and `inbox/tell/*.json` (via tellFilesForKey) to notice a
14
+ // session that asked questions, got them answered, and then reported
15
+ // nothing - "Why it didnt output anything?!". Move 2's contract has no rows
16
+ // op that lists a session's own board writes (the closest, `collect_session`,
17
+ // answers "what came back", not "what did this session ask"), so there is
18
+ // nothing this file can read to reconstruct that account any more. Flagged
19
+ // as a deviation rather than silently dropped; a rows-based equivalent needs
20
+ // its own op (e.g. a `session_asks` view) and is not in this contract.
21
+
22
+ import { classifyExit } from "./exitClass.mjs";
23
+ import { callTool as hostedCallTool } from "./hosted.mjs";
24
+
25
+ /** The verdict, in words. The default set is the receipt's own (`finished` /
26
+ * `ended with an error result` / `killed at wall clock` / `exited N`): the app
27
+ * parses it out of the row's `result`, and acp.test.mjs and codex.test.mjs pin
28
+ * it, so it never changes. `card` is the person-facing word for the one reply
29
+ * the bridge files, which is only ever a clean finish: "Finished". Every other
30
+ * ending is written by the app from the row. */
31
+ function verdictWord(outcome, { card = false } = {}) {
32
+ if (card) return "Finished";
33
+ if (outcome.finished && !outcome.finishedWithError) return "finished";
34
+ if (outcome.finishedWithError) return "ended with an error result";
35
+ if (outcome.stalled) return "stopped responding";
36
+ if (outcome.timedOut) return "killed at wall clock";
37
+ if (outcome.code !== undefined) {
38
+ const cls = classifyExit(outcome.code, outcome.signal);
39
+ return cls.isCrash ? cls.description : outcome.signal ? `ended on ${outcome.signal}` : `exited ${outcome.code}`;
40
+ }
41
+ return "ended";
42
+ }
43
+
44
+ /** The figures an envelope carries: turns, duration, and (receipt only)
45
+ * how full the context got. Neither carries a price: the app never shows
46
+ * what a session cost (#151, #281); the cost stays in the sidecar for the
47
+ * ledger. `card` keeps only the duration: the card says "Took 4 minutes"
48
+ * (#470, #735), and turns and context are developer counts. */
49
+ function figuresOf(outcome, { card = false } = {}) {
50
+ const env = outcome.envelope;
51
+ const figures = [];
52
+ if (!card && env?.num_turns !== undefined) {
53
+ figures.push(`${env.num_turns} turn${env.num_turns === 1 ? "" : "s"}`);
54
+ }
55
+ if (env?.duration_ms !== undefined) {
56
+ // Under a minute in seconds, rounded up (QA 2026-10-01, e4): a 27 s
57
+ // Codex run read "0m".
58
+ const ms = Number(env.duration_ms);
59
+ figures.push(ms < 60_000 ? `${Math.max(1, Math.ceil(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`);
60
+ }
61
+ if (!card && outcome.contextFill) {
62
+ const k = Math.round(outcome.contextFill / 1000);
63
+ figures.push(`context ${k}k`);
64
+ }
65
+ return figures;
66
+ }
67
+
68
+ /** Format the verdict line from the envelope and outcome, then the figures:
69
+ * "finished; 41 turns, 38m, context 212k". The receipt reads it as it
70
+ * is; `{card: true}` gives the person-facing words (see `verdictWord`).
71
+ * It reads only `outcome`, never the board. */
72
+ export function formatVerdict(outcome, options = {}) {
73
+ const figures = figuresOf(outcome, options);
74
+ const verdict = verdictWord(outcome, options);
75
+ return figures.length > 0 ? `${verdict}; ${figures.join(", ")}` : verdict;
76
+ }
77
+
78
+ /** The idea whose run this is: the FIRST id on the request, because a
79
+ * selection of several ideas files one request and a run belongs to one
80
+ * idea. Absent for a CLI request, which has no idea behind it.
81
+ * Unchanged from the file-based runner. */
82
+ export function fromIdOf(request) {
83
+ const ids = request?.nodeIds;
84
+ return Array.isArray(ids) && ids.length > 0 ? String(ids[0]) : undefined;
85
+ }
86
+
87
+ /** One `propose` call, `ops` in the INBOX_OPS.md vocabulary, under the run's
88
+ * own session key. Never throws: a board write that fails is logged by the
89
+ * caller and must not take a session down - the run's own receipt (the row
90
+ * itself) is the durable record either way. */
91
+ async function proposeOnBoard(session, ops, label, { callTool = hostedCallTool, env } = {}) {
92
+ const outcome = await callTool("propose", { ops, label }, { sessionKey: session.key, env });
93
+ return { ok: !outcome.isError, detail: outcome.text };
94
+ }
95
+
96
+ /** A note on the idea a session's run belongs to - the idea named
97
+ * by `frameId` (the run's `from_id`, carried since brief 26 retired the
98
+ * session frame). ROWS VERSION: `propose({ops:[{call:"addComment", ...}]})`
99
+ * instead of an ops file; same reasoning as ever for why this is a COMMENT
100
+ * and not a `report` - it cannot clobber whatever the session itself said
101
+ * with `report()`, where a second report keyed to the same session would.
102
+ *
103
+ * `options.fields` rides beside `id` and `text` on the op (brief 52: agentId,
104
+ * runId, summary, images and the links). Absent, the op is the two-field one
105
+ * it has always been. */
106
+ export async function commentOnAnchor(session, frameId, text, options = {}) {
107
+ if (!frameId) return null;
108
+ const { fields, ...rest } = options;
109
+ const result = await proposeOnBoard(
110
+ session,
111
+ [{ call: "addComment", args: { id: frameId, text, ...(fields ?? {}) } }],
112
+ `Session ${session.key} outcome`,
113
+ rest,
114
+ );
115
+ return result.ok ? result.detail : null;
116
+ }
117
+
118
+ /** A question through `ask_user`, under the run's own session key. It lands as
119
+ * a question note on the idea named by `fromId` (the run's own idea), not as a
120
+ * circle of its own. Used for the one case the runner itself ever asks
121
+ * anything: a failed merge. Returns the question NOTE id parsed out of
122
+ * `ask_user`'s reply text (`pending (question_id: <noteId>)`), or null when the call failed or the id could not
123
+ * be found in the reply. */
124
+ export async function askUserOnBoard(session, question, { why, fromId, options: choices, recommendation, images, purpose } = {}, options = {}) {
125
+ const outcome = await (options.callTool ?? hostedCallTool)(
126
+ "ask_user",
127
+ { question, why, ...(fromId ? { from_id: fromId } : {}), ...(choices ? { options: choices } : {}), ...(recommendation ? { recommendation } : {}), ...(images?.length > 0 ? { images } : {}), ...(purpose ? { purpose } : {}) },
128
+ { sessionKey: session.key, env: options.env },
129
+ );
130
+ if (outcome.isError) return { ok: false, detail: outcome.text };
131
+ const match = /question_id:\s*(\S+)\)/.exec(outcome.text ?? "");
132
+ return { ok: true, questionId: match?.[1] ?? null, detail: outcome.text };
133
+ }
134
+
135
+ // THE ONE REPLY AT THE END (brief 52, ruling 4 and 5.5). A session used to
136
+ // leave an outcome comment of verdict lines beside whatever it said itself.
137
+ // Now it leaves one reply on its idea: the engine's closing words first, then
138
+ // one line of figures. The hosted `addComment` on an idea lands it as a
139
+ // `reply` note carrying the agent, the run, a caption and the links.
140
+
141
+ /** The closing words, capped at 2,000 characters with the END kept: the part
142
+ * of a long answer a person needs is the part that says where it landed. */
143
+ export const CLOSING_TEXT_CAP = 2000;
144
+ export function closingTextOf(outcome) {
145
+ const raw = typeof outcome.envelope?.result === "string" ? outcome.envelope.result.trim() : "";
146
+ return raw.length > CLOSING_TEXT_CAP ? `…${raw.slice(raw.length - (CLOSING_TEXT_CAP - 1)).trimStart()}` : raw;
147
+ }
148
+
149
+ /** The first sentence of the closing words, for the circle's caption. */
150
+ export function firstSentenceOf(text) {
151
+ const clean = String(text ?? "").replace(/^[\s#>*_-]+/, "");
152
+ const line = clean.split("\n")[0].trim();
153
+ const stop = /[.!?](\s|$)/.exec(line);
154
+ const sentence = (stop ? line.slice(0, stop.index + 1) : line).trim();
155
+ return sentence.length > 160 ? `${sentence.slice(0, 159).trimEnd()}…` : sentence;
156
+ }
157
+
158
+ /** Did this run finish cleanly, so that the bridge files its closing reply?
159
+ * The app writes the ending for everything else (a stop, a session that died,
160
+ * a timeout, a binary that would not start) from the row. */
161
+ export function finishedCleanly(outcome, { stopped = false } = {}) {
162
+ if (stopped || outcome.spawnError) return false;
163
+ if (outcome.finishedWithError || outcome.stalled || outcome.timedOut && !outcome.finished) return false;
164
+ return (outcome.finished === true || outcome.code === 0) && outcome.ok !== false;
165
+ }
166
+
167
+ /** The sentence a run that worked in a non-git folder itself and changed
168
+ * files there ends with (#483): no copy was made, so Throw away is not
169
+ * offered and the reply says why. Kept word for word with the issue. */
170
+ export const IN_PLACE_NOTICE = "This ran in your folder itself, so Throw away cannot undo it.";
171
+
172
+ /** The reply's text and caption for a run that finished. Pure.
173
+ *
174
+ * The closing words first (capped, the end kept), then ONE line led by
175
+ * "Finished" that carries only how long it took: "Finished; 4m". The card
176
+ * footer reads "Took 4 minutes" (#470, #735); turns, the branch and the file
177
+ * count are developer words and stay on the session screen.
178
+ * A run with no closing words still says it finished. */
179
+ export function composeReply(outcome, { inPlace } = {}) {
180
+ const said = closingTextOf(outcome);
181
+ const closing = inPlace ? [said, IN_PLACE_NOTICE].filter(Boolean).join("\n\n") : said;
182
+ const figures = figuresOf(outcome, { card: true });
183
+ const verdict = verdictWord(outcome, { card: true });
184
+ const line = figures.length > 0 ? `${verdict}; ${figures.join(", ")}` : verdict;
185
+ return {
186
+ text: closing ? `${closing}\n\n${line}` : line,
187
+ summary: closing ? firstSentenceOf(closing) || undefined : undefined,
188
+ };
189
+ }
190
+
191
+ /** File the run's ONE closing reply on its own idea, and only when the run
192
+ * finished (`finishedCleanly`). The caller (runnerCompletion.mjs) flips the
193
+ * run row to its ending status with `runnerApi.update`; this only posts the
194
+ * reply, so a run with no idea behind it (`frameId` null) still gets its row
195
+ * updated although nothing is drawn. Every other ending writes nothing here:
196
+ * the app reads the row and writes it.
197
+ *
198
+ * `options`: `{callTool, env}` plus the run's own facts - `request` (its
199
+ * agentId), `stopped`, `worktree` ({branch, files} of the copy it made),
200
+ * `inPlace` (it changed files in a non-git folder itself, #483), `preview` (the dev
201
+ * server's address), `report` (a report frame's id) and `images` (pictures to
202
+ * show under the reply). Every one is optional. */
203
+ export async function reportOutcomeOnBoard(session, outcome, frameId, options = {}) {
204
+ if (!frameId) return null;
205
+ const { request, stopped, worktree, inPlace, preview, report, images, agentId, ...rest } = options;
206
+ if (!finishedCleanly(outcome, { stopped })) return null;
207
+ const { text, summary } = composeReply(outcome, { inPlace });
208
+ const who = typeof agentId === "string" && agentId.trim() !== "" ? agentId.trim()
209
+ : typeof request?.agentId === "string" && request.agentId.trim() !== "" ? request.agentId.trim() : undefined;
210
+ const fields = {
211
+ ...(outcome.result ? { result: outcome.result } : {}),
212
+ ...(who ? { agentId: who } : {}),
213
+ runId: session.key,
214
+ ...(summary ? { summary } : {}),
215
+ ...(Array.isArray(images) && images.length > 0 ? { images: images.slice(0, 6) } : {}),
216
+ ...(worktree?.branch ? { branch: worktree.branch } : {}),
217
+ ...(Number.isInteger(worktree?.files) ? { files: worktree.files } : {}),
218
+ ...(typeof preview === "string" && preview !== "" ? { preview } : {}),
219
+ ...(typeof report === "string" && report !== "" ? { report } : {}),
220
+ };
221
+ // The plain line when a screen changed but no pictures could be taken (#2194).
222
+ const lines = [text, outcome.result?.fixAttempted && "Coding tried one fix.", outcome.picturesNote].filter(Boolean);
223
+ const closing = lines.join("\n\n");
224
+ return commentOnAnchor(session, frameId, closing, { ...rest, fields });
225
+ }