viewable-one 0.0.0-stage → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +767 -0
- package/lib/codexApps.mjs +344 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1640 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +145 -0
- package/lib/runnerWorkspace.mjs +177 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,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
|
+
}
|