@foldrun/core 0.3.0 → 0.5.0

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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -0,0 +1,201 @@
1
+ import type { EventExtra } from "./step-exec.ts";
2
+ import type { StepMessage, StepQuestion } from "./store.ts";
3
+ /** What a trace event carries when a person was involved. */
4
+ export type OperatorEvent = {
5
+ kind: "asked";
6
+ id: string;
7
+ question: string;
8
+ options?: string[];
9
+ } | {
10
+ kind: "answered";
11
+ id: string;
12
+ answer: string;
13
+ by?: string | null;
14
+ } | {
15
+ kind: "unanswered";
16
+ id: string;
17
+ } | {
18
+ kind: "message";
19
+ text: string;
20
+ by?: string | null;
21
+ at: string;
22
+ };
23
+ export interface OperatorMessage {
24
+ text: string;
25
+ by?: string | null;
26
+ at: string;
27
+ }
28
+ /** The pod's side of the line. `null` when there is no proxy (the CLI, a
29
+ * compose install without one): then nobody can answer or write in. */
30
+ export interface OperatorChannel {
31
+ ask(question: string, options?: string[]): Promise<{
32
+ id: string;
33
+ }>;
34
+ /** Waits up to `maxMs` for this question's answer; null when none came. */
35
+ poll(id: string, maxMs: number): Promise<{
36
+ answer: string;
37
+ by?: string | null;
38
+ } | null>;
39
+ inbox(): Promise<OperatorMessage[]>;
40
+ /** Nobody will read this question's answer now (the wait timed out):
41
+ * stop offering it to people. Best effort; optional for fakes. */
42
+ close?(id: string): Promise<void>;
43
+ }
44
+ export declare const QUESTION_MAX = 2000;
45
+ export declare const OPTION_MAX = 200;
46
+ export declare const OPTIONS_MAX = 10;
47
+ export declare const ANSWER_MAX = 4000;
48
+ export declare const MESSAGE_MAX = 4000;
49
+ /** One long-poll round trip — under any proxy's idle cut-off. */
50
+ export declare const POLL_MS = 25000;
51
+ export declare const ASK_DEFAULT_SEC: number;
52
+ export declare const ASK_MAX_SEC: number;
53
+ /** `http://w:8090/e/<lease>` → the proxy's origin and the lease token. */
54
+ export declare function operatorEndpoint(egress: string | undefined | null): {
55
+ base: string;
56
+ token: string;
57
+ } | null;
58
+ /** The channel over the egress proxy. `fetchImpl` is for tests. */
59
+ export declare function httpChannel(egress: string | undefined | null, fetchImpl?: typeof fetch): OperatorChannel | null;
60
+ /** `ask: {timeout: 2h}` in an agent's frontmatter → seconds, clamped. */
61
+ export declare function askTimeoutSec(front: Record<string, unknown>): number;
62
+ /** "90s", "30m", "2h", "1d" or a number of seconds; null when unreadable. */
63
+ export declare function parseDuration(raw: unknown): number | null;
64
+ export declare const NO_ANSWER = "No one answered in time. Decide for yourself if the choice is safe and say what you chose and why; if it is not, stop and reply BLOCKED with the question.";
65
+ type Emit = (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
66
+ /** The in-sandbox `ask_person` tool. With no channel (no proxy), `local`
67
+ * answers instead — the CLI prompts on a terminal — or nobody does. */
68
+ export declare function buildAskTool(opts: {
69
+ channel: OperatorChannel | null;
70
+ timeoutSec: number;
71
+ emit: Emit;
72
+ local?: (question: string, options?: string[]) => Promise<string | null>;
73
+ now?: () => number;
74
+ }): {
75
+ server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
76
+ /** The tool's own function, for tests. */
77
+ call: (args: {
78
+ question: string;
79
+ options?: string[];
80
+ }) => Promise<{
81
+ [x: string]: unknown;
82
+ _meta?: {
83
+ [x: string]: unknown;
84
+ progressToken?: string | number | undefined;
85
+ "io.modelcontextprotocol/related-task"?: {
86
+ taskId: string;
87
+ } | undefined;
88
+ } | undefined;
89
+ content: ({
90
+ type: "text";
91
+ text: string;
92
+ annotations?: {
93
+ audience?: ("assistant" | "user")[] | undefined;
94
+ priority?: number | undefined;
95
+ lastModified?: string | undefined;
96
+ } | undefined;
97
+ _meta?: {
98
+ [x: string]: unknown;
99
+ } | undefined;
100
+ } | {
101
+ type: "image";
102
+ data: string;
103
+ mimeType: string;
104
+ annotations?: {
105
+ audience?: ("assistant" | "user")[] | undefined;
106
+ priority?: number | undefined;
107
+ lastModified?: string | undefined;
108
+ } | undefined;
109
+ _meta?: {
110
+ [x: string]: unknown;
111
+ } | undefined;
112
+ } | {
113
+ type: "audio";
114
+ data: string;
115
+ mimeType: string;
116
+ annotations?: {
117
+ audience?: ("assistant" | "user")[] | undefined;
118
+ priority?: number | undefined;
119
+ lastModified?: string | undefined;
120
+ } | undefined;
121
+ _meta?: {
122
+ [x: string]: unknown;
123
+ } | undefined;
124
+ } | {
125
+ uri: string;
126
+ description?: string | undefined;
127
+ mimeType?: string | undefined;
128
+ size?: number | undefined;
129
+ annotations?: {
130
+ audience?: ("assistant" | "user")[] | undefined;
131
+ priority?: number | undefined;
132
+ lastModified?: string | undefined;
133
+ } | undefined;
134
+ _meta?: {
135
+ [x: string]: unknown;
136
+ } | undefined;
137
+ icons?: {
138
+ src: string;
139
+ mimeType?: string | undefined;
140
+ sizes?: string[] | undefined;
141
+ theme?: "dark" | "light" | undefined;
142
+ }[] | undefined;
143
+ name: string;
144
+ title?: string | undefined;
145
+ type: "resource_link";
146
+ } | {
147
+ type: "resource";
148
+ resource: {
149
+ uri: string;
150
+ mimeType?: string | undefined;
151
+ _meta?: {
152
+ [x: string]: unknown;
153
+ } | undefined;
154
+ text: string;
155
+ } | {
156
+ uri: string;
157
+ mimeType?: string | undefined;
158
+ _meta?: {
159
+ [x: string]: unknown;
160
+ } | undefined;
161
+ blob: string;
162
+ };
163
+ annotations?: {
164
+ audience?: ("assistant" | "user")[] | undefined;
165
+ priority?: number | undefined;
166
+ lastModified?: string | undefined;
167
+ } | undefined;
168
+ _meta?: {
169
+ [x: string]: unknown;
170
+ } | undefined;
171
+ })[];
172
+ structuredContent?: {
173
+ [x: string]: unknown;
174
+ } | undefined;
175
+ isError?: boolean | undefined;
176
+ }>;
177
+ toolNames: string[];
178
+ promptLines: string[];
179
+ };
180
+ /** The granted name of the tool — the same on every path. */
181
+ export declare const ASK_TOOL = "mcp__foldrun_ask__ask_person";
182
+ /** The line the system prompt gets for it. */
183
+ export declare function askPromptLine(timeoutSec: number): string;
184
+ /** Reads the run's inbox after tool calls and turns messages into context
185
+ * for the model. At most one read every `minGapMs`; a failed read is
186
+ * silent (the next tool call tries again). */
187
+ export declare function inboxReader(channel: OperatorChannel | null, emit: Emit, minGapMs?: number, now?: () => number): () => Promise<string | null>;
188
+ /** Fold one operator event into the step's record. Pure; the runner calls
189
+ * it as events arrive, whichever sandbox sent them. */
190
+ export declare function applyOperatorEvent(step: {
191
+ questions?: StepQuestion[];
192
+ messages?: StepMessage[];
193
+ }, ev: OperatorEvent, at: string): void;
194
+ /** The question a step is waiting on, if any. */
195
+ export declare const openQuestion: (step: {
196
+ questions?: StepQuestion[];
197
+ }) => StepQuestion | null;
198
+ /** Is this — read off the sandbox's stdout, so untrusted — an operator
199
+ * event of the right shape and size? Anything else is dropped. */
200
+ export declare function isOperatorEvent(v: unknown): v is OperatorEvent;
201
+ export {};
@@ -0,0 +1,251 @@
1
+ // A person in the loop while a step runs: the agent asks, a person answers,
2
+ // or a person says something the agent should hear.
3
+ //
4
+ // Until this, the only way a person reached a step was before it started —
5
+ // `ask:`, an approval gate, `wait: event` — and the answer arrived as
6
+ // prompt text. An agent that found, halfway through, that it needed a
7
+ // decision had to fail or guess.
8
+ //
9
+ // Two things, one line out of the sandbox. A run pod has no ingress; the
10
+ // only live door is the worker's egress proxy, reached at the per-step
11
+ // lease address the pod already holds (FOLDRUN_EGRESS = http://<w>/e/<lease>).
12
+ // The lease names the account and the run, so a step can only ever ask in,
13
+ // or read the inbox of, its own run.
14
+ //
15
+ // tools: [ask] the agent gets `ask_person` — POST the question, then
16
+ // long-poll for the answer; the step's sandbox is held
17
+ // (and charged) while it waits, up to `ask: {timeout}`
18
+ // every step after each tool call, its run's inbox is read and any
19
+ // message is handed to the model as context
20
+ //
21
+ // Both leave a trace event with an `operator` field, which the runner turns
22
+ // into step.questions / step.messages on the record.
23
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
24
+ import { z } from "zod";
25
+ export const QUESTION_MAX = 2_000;
26
+ export const OPTION_MAX = 200;
27
+ export const OPTIONS_MAX = 10;
28
+ export const ANSWER_MAX = 4_000;
29
+ export const MESSAGE_MAX = 4_000;
30
+ /** One long-poll round trip — under any proxy's idle cut-off. */
31
+ export const POLL_MS = 25_000;
32
+ export const ASK_DEFAULT_SEC = 30 * 60;
33
+ export const ASK_MAX_SEC = 24 * 60 * 60;
34
+ /** `http://w:8090/e/<lease>` → the proxy's origin and the lease token. */
35
+ export function operatorEndpoint(egress) {
36
+ if (!egress)
37
+ return null;
38
+ const m = egress.match(/^(https?:\/\/[^/]+)\/e\/([A-Za-z0-9_-]{16,})\/?$/);
39
+ return m ? { base: m[1], token: m[2] } : null;
40
+ }
41
+ /** The channel over the egress proxy. `fetchImpl` is for tests. */
42
+ export function httpChannel(egress, fetchImpl = fetch) {
43
+ const ep = operatorEndpoint(egress);
44
+ if (!ep)
45
+ return null;
46
+ const json = async (res) => {
47
+ const body = (await res.json().catch(() => ({})));
48
+ if (!res.ok)
49
+ throw new Error(String(body.error?.message ?? `proxy answered ${res.status}`));
50
+ return body;
51
+ };
52
+ return {
53
+ async ask(question, options) {
54
+ const body = await json(await fetchImpl(`${ep.base}/ask/${ep.token}`, {
55
+ method: "POST",
56
+ headers: { "content-type": "application/json" },
57
+ body: JSON.stringify({ question, options }),
58
+ signal: AbortSignal.timeout(15_000),
59
+ }));
60
+ return { id: String(body.id) };
61
+ },
62
+ async poll(id, maxMs) {
63
+ const wait = Math.max(0, Math.min(maxMs, POLL_MS));
64
+ const body = await json(await fetchImpl(`${ep.base}/ask/${ep.token}/${encodeURIComponent(id)}?wait=${wait}`, {
65
+ signal: AbortSignal.timeout(wait + 15_000),
66
+ }));
67
+ return typeof body.answer === "string" ? { answer: body.answer, by: body.by ?? null } : null;
68
+ },
69
+ async inbox() {
70
+ const body = await json(await fetchImpl(`${ep.base}/inbox/${ep.token}`, { signal: AbortSignal.timeout(3_000) }));
71
+ return Array.isArray(body.messages) ? body.messages : [];
72
+ },
73
+ async close(id) {
74
+ await json(await fetchImpl(`${ep.base}/ask/${ep.token}/${encodeURIComponent(id)}`, {
75
+ method: "DELETE",
76
+ signal: AbortSignal.timeout(5_000),
77
+ }));
78
+ },
79
+ };
80
+ }
81
+ /** `ask: {timeout: 2h}` in an agent's frontmatter → seconds, clamped. */
82
+ export function askTimeoutSec(front) {
83
+ const raw = front.ask?.timeout;
84
+ const sec = parseDuration(raw);
85
+ return sec === null ? ASK_DEFAULT_SEC : Math.min(Math.max(sec, 30), ASK_MAX_SEC);
86
+ }
87
+ /** "90s", "30m", "2h", "1d" or a number of seconds; null when unreadable. */
88
+ export function parseDuration(raw) {
89
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0)
90
+ return raw;
91
+ if (typeof raw !== "string")
92
+ return null;
93
+ const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
94
+ if (!m)
95
+ return null;
96
+ const mult = { s: 1, m: 60, h: 3600, d: 86400 }[(m[2] ?? "s").toLowerCase()];
97
+ return Math.round(Number(m[1]) * mult);
98
+ }
99
+ export const NO_ANSWER = "No one answered in time. Decide for yourself if the choice is safe and say what you chose and why; if it is not, stop and reply BLOCKED with the question.";
100
+ /** The in-sandbox `ask_person` tool. With no channel (no proxy), `local`
101
+ * answers instead — the CLI prompts on a terminal — or nobody does. */
102
+ export function buildAskTool(opts) {
103
+ const now = opts.now ?? Date.now;
104
+ const ask = tool("ask_person", "Ask the person running this desk a question and wait for their answer. Use it for a decision you cannot make safely yourself — not for information you can look up. Give a short list of options when the answer is one of a few choices. Returns the answer, or says nobody answered in time.", {
105
+ question: z.string().describe("The complete, self-contained question — the person has not read your work"),
106
+ options: z.array(z.string()).optional().describe("Up to 10 short choices, when the answer is one of them"),
107
+ }, async ({ question, options }) => {
108
+ const q = String(question).trim().slice(0, QUESTION_MAX);
109
+ const opts2 = (options ?? []).map((o) => String(o).trim().slice(0, OPTION_MAX)).filter(Boolean).slice(0, OPTIONS_MAX);
110
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
111
+ if (!q)
112
+ return { ...text("ask_person needs a question."), isError: true };
113
+ if (!opts.channel) {
114
+ const local = opts.local ? await opts.local(q, opts2.length ? opts2 : undefined) : null;
115
+ const id = "local";
116
+ opts.emit("info", `asked a person: ${q}`, { operator: { kind: "asked", id, question: q, ...(opts2.length ? { options: opts2 } : {}) } });
117
+ if (local === null || local.trim() === "") {
118
+ opts.emit("info", "no answer — nobody can answer here", { operator: { kind: "unanswered", id } });
119
+ return text(NO_ANSWER);
120
+ }
121
+ opts.emit("info", `answered: ${local}`, { operator: { kind: "answered", id, answer: local, by: "terminal" } });
122
+ return text(`The person answered: ${local}`);
123
+ }
124
+ let id;
125
+ try {
126
+ ({ id } = await opts.channel.ask(q, opts2.length ? opts2 : undefined));
127
+ }
128
+ catch (err) {
129
+ return { ...text(`Could not ask: ${err instanceof Error ? err.message : String(err)}. ${NO_ANSWER}`), isError: true };
130
+ }
131
+ opts.emit("info", `asked a person: ${q}`, { operator: { kind: "asked", id, question: q, ...(opts2.length ? { options: opts2 } : {}) } });
132
+ const deadline = now() + opts.timeoutSec * 1000;
133
+ while (now() < deadline) {
134
+ let got = null;
135
+ try {
136
+ got = await opts.channel.poll(id, deadline - now());
137
+ }
138
+ catch {
139
+ // A dropped long-poll is retried; the deadline is what ends it.
140
+ await new Promise((r) => setTimeout(r, 2_000));
141
+ continue;
142
+ }
143
+ if (got) {
144
+ const answer = got.answer.slice(0, ANSWER_MAX);
145
+ opts.emit("info", `answered${got.by ? ` by ${got.by}` : ""}: ${answer}`, { operator: { kind: "answered", id, answer, by: got.by ?? null } });
146
+ return text(`The person answered: ${answer}`);
147
+ }
148
+ }
149
+ // Close it on the proxy, so the dashboard stops offering a box whose
150
+ // answer nobody would read. Best effort: a failure changes nothing here.
151
+ try {
152
+ await opts.channel.close?.(id);
153
+ }
154
+ catch {
155
+ /* the proxy's TTL clears it eventually */
156
+ }
157
+ opts.emit("info", `no answer after ${Math.round(opts.timeoutSec / 60)} min`, { operator: { kind: "unanswered", id } });
158
+ return text(NO_ANSWER);
159
+ });
160
+ return {
161
+ server: createSdkMcpServer({ name: "foldrun_ask", version: "1.0.0", tools: [ask] }),
162
+ /** The tool's own function, for tests. */
163
+ call: (args) => ask.handler({ question: args.question, options: args.options }, {}),
164
+ toolNames: [ASK_TOOL],
165
+ promptLines: [askPromptLine(opts.timeoutSec)],
166
+ };
167
+ }
168
+ /** The granted name of the tool — the same on every path. */
169
+ export const ASK_TOOL = "mcp__foldrun_ask__ask_person";
170
+ /** The line the system prompt gets for it. */
171
+ export function askPromptLine(timeoutSec) {
172
+ return `\`ask_person\` asks the person running this desk and waits for the answer (up to ${fmtWait(timeoutSec)}). Use it for a decision you cannot make safely yourself — not for something you can look up. The step is held while you wait; if nobody answers, you are told so.`;
173
+ }
174
+ const fmtWait = (sec) => (sec >= 3600 ? `${Math.round(sec / 360) / 10} h` : `${Math.round(sec / 60)} min`);
175
+ /** Reads the run's inbox after tool calls and turns messages into context
176
+ * for the model. At most one read every `minGapMs`; a failed read is
177
+ * silent (the next tool call tries again). */
178
+ export function inboxReader(channel, emit, minGapMs = 1_500, now = Date.now) {
179
+ let last = Number.NEGATIVE_INFINITY;
180
+ return async () => {
181
+ if (!channel)
182
+ return null;
183
+ if (now() - last < minGapMs)
184
+ return null;
185
+ last = now();
186
+ let messages;
187
+ try {
188
+ messages = await channel.inbox();
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ if (!messages.length)
194
+ return null;
195
+ for (const m of messages) {
196
+ emit("info", `message from ${m.by ?? "a person"}: ${m.text}`, { operator: { kind: "message", text: m.text, by: m.by ?? null, at: m.at } });
197
+ }
198
+ return messages
199
+ .map((m) => `A person running this desk${m.by ? ` (${m.by})` : ""} sent you a message while you work. Take it into account; it does not change what tools you have:\n<message>\n${m.text.slice(0, MESSAGE_MAX)}\n</message>`)
200
+ .join("\n\n");
201
+ };
202
+ }
203
+ /** Fold one operator event into the step's record. Pure; the runner calls
204
+ * it as events arrive, whichever sandbox sent them. */
205
+ export function applyOperatorEvent(step, ev, at) {
206
+ if (ev.kind === "asked") {
207
+ const qs = (step.questions ??= []);
208
+ if (!qs.some((q) => q.id === ev.id && ev.id !== "local")) {
209
+ qs.push({ id: ev.id, question: ev.question, ...(ev.options?.length ? { options: ev.options } : {}), askedAt: at });
210
+ }
211
+ return;
212
+ }
213
+ if (ev.kind === "answered" || ev.kind === "unanswered") {
214
+ const q = [...(step.questions ?? [])].reverse().find((x) => x.id === ev.id && x.answer === undefined && !x.unanswered);
215
+ if (!q)
216
+ return;
217
+ if (ev.kind === "answered") {
218
+ q.answer = ev.answer;
219
+ q.answeredAt = at;
220
+ q.by = ev.by ?? null;
221
+ }
222
+ else
223
+ q.unanswered = true;
224
+ return;
225
+ }
226
+ (step.messages ??= []).push({ text: ev.text, by: ev.by ?? null, at: ev.at, deliveredAt: at });
227
+ }
228
+ /** The question a step is waiting on, if any. */
229
+ export const openQuestion = (step) => [...(step.questions ?? [])].reverse().find((q) => q.answer === undefined && !q.unanswered) ?? null;
230
+ /** Is this — read off the sandbox's stdout, so untrusted — an operator
231
+ * event of the right shape and size? Anything else is dropped. */
232
+ export function isOperatorEvent(v) {
233
+ if (!v || typeof v !== "object")
234
+ return false;
235
+ const o = v;
236
+ const str = (x, max) => typeof x === "string" && x.length <= max;
237
+ const id = str(o.id, 64);
238
+ switch (o.kind) {
239
+ case "asked":
240
+ return id && str(o.question, QUESTION_MAX) &&
241
+ (o.options === undefined || (Array.isArray(o.options) && o.options.length <= OPTIONS_MAX && o.options.every((x) => str(x, OPTION_MAX))));
242
+ case "answered":
243
+ return id && str(o.answer, ANSWER_MAX) && (o.by === undefined || o.by === null || str(o.by, 200));
244
+ case "unanswered":
245
+ return id;
246
+ case "message":
247
+ return str(o.text, MESSAGE_MAX) && str(o.at, 40) && (o.by === undefined || o.by === null || str(o.by, 200));
248
+ default:
249
+ return false;
250
+ }
251
+ }
@@ -31,3 +31,36 @@ export declare function dataRoot(): string;
31
31
  * no account and no tenant there, so the wrapper directories collapse away.
32
32
  */
33
33
  export declare function singleWorkspace(): string | null;
34
+ /**
35
+ * The account scope in single-workspace mode: where `AGENTS.md` and
36
+ * `library/` live for the workspace this process is pinned to. Null in the
37
+ * multi-tenant layout, where the account is a tenant directory under the data
38
+ * root and nothing has to be inferred.
39
+ *
40
+ * It used to be, everywhere, `path.resolve(single, "..")`. That is right for a
41
+ * flat workspace and wrong for one inside an account folder, where the parent
42
+ * is `workspaces/` — a directory with no AGENTS.md and no library in it. Every
43
+ * `skills:`, `tools:` and `scripts:` that named something shared then resolved
44
+ * against an empty directory and came back missing. `accountRootFor` knows the
45
+ * difference; `FOLDRUN_ACCOUNT` overrides both, for an embedder that has
46
+ * already decided.
47
+ */
48
+ export declare function singleAccountRoot(): string | null;
49
+ /** Without leading and/or trailing slashes. */
50
+ export declare function trimSlashes(s: string, where?: "start" | "end" | "both"): string;
51
+ /** Without any of `chars` at the ends — the linear form of
52
+ * `.replace(/^[chars]+/, "").replace(/[chars]+$/, "")`. */
53
+ export declare function trimChars(s: string, chars: string): string;
54
+ /** The largest file a person (or the gallery) writes into a workspace or the
55
+ * library through the store: 512 KB. A folder tool's program is one file,
56
+ * and the gallery browser's passed 256 KB on 2026-09-29. */
57
+ export declare const MAX_EDITABLE_FILE: number;
58
+ /**
59
+ * Every entry under `dir`, relative, like `fs.readdirSync(dir, { recursive:
60
+ * true })` — except that a link to a directory is listed and NOT entered.
61
+ * Node's recursive readdir follows directory links, so a link back up the
62
+ * tree (the `workspace` link a step holds in its agent folder, confine.ts)
63
+ * is walked about forty levels deep before the kernel stops it, each level
64
+ * a full copy of the workspace.
65
+ */
66
+ export declare function entriesNoFollow(dir: string): string[];
package/dist/src/paths.js CHANGED
@@ -21,6 +21,7 @@
21
21
  // caches: the whole point is that the answer can change between calls.
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
+ import { accountRootFor } from "./layout.js";
24
25
  /**
25
26
  * The nearest enclosing project root, found by walking up from `from` looking
26
27
  * for a `.git` directory. Null when there is none — an installed package in
@@ -76,3 +77,89 @@ export function dataRoot() {
76
77
  export function singleWorkspace() {
77
78
  return process.env.FOLDRUN_WORKSPACE ?? null;
78
79
  }
80
+ /**
81
+ * The account scope in single-workspace mode: where `AGENTS.md` and
82
+ * `library/` live for the workspace this process is pinned to. Null in the
83
+ * multi-tenant layout, where the account is a tenant directory under the data
84
+ * root and nothing has to be inferred.
85
+ *
86
+ * It used to be, everywhere, `path.resolve(single, "..")`. That is right for a
87
+ * flat workspace and wrong for one inside an account folder, where the parent
88
+ * is `workspaces/` — a directory with no AGENTS.md and no library in it. Every
89
+ * `skills:`, `tools:` and `scripts:` that named something shared then resolved
90
+ * against an empty directory and came back missing. `accountRootFor` knows the
91
+ * difference; `FOLDRUN_ACCOUNT` overrides both, for an embedder that has
92
+ * already decided.
93
+ */
94
+ export function singleAccountRoot() {
95
+ const single = singleWorkspace();
96
+ if (!single)
97
+ return null;
98
+ if (process.env.FOLDRUN_ACCOUNT)
99
+ return path.resolve(process.env.FOLDRUN_ACCOUNT);
100
+ return accountRootFor(path.resolve(single));
101
+ }
102
+ // ------------------------------------------------------- linear trimming
103
+ //
104
+ // `s.replace(/\/+$/, "")` is the obvious way to drop trailing slashes and
105
+ // it is quadratic: the engine tries `\/+` from every position and fails at
106
+ // `$` each time, so a string of ten thousand slashes is fifty million
107
+ // steps. Several of these strings come from a customer's own files — a
108
+ // base_url, a tool's base, a storage prefix — which is exactly the input
109
+ // that must not be able to stall a worker. A loop is linear and reads the
110
+ // same.
111
+ /** Without leading and/or trailing slashes. */
112
+ export function trimSlashes(s, where = "end") {
113
+ let a = 0;
114
+ let b = s.length;
115
+ if (where !== "end")
116
+ while (a < b && s[a] === "/")
117
+ a++;
118
+ if (where !== "start")
119
+ while (b > a && s[b - 1] === "/")
120
+ b--;
121
+ return s.slice(a, b);
122
+ }
123
+ /** Without any of `chars` at the ends — the linear form of
124
+ * `.replace(/^[chars]+/, "").replace(/[chars]+$/, "")`. */
125
+ export function trimChars(s, chars) {
126
+ let a = 0;
127
+ let b = s.length;
128
+ while (a < b && chars.includes(s[a]))
129
+ a++;
130
+ while (b > a && chars.includes(s[b - 1]))
131
+ b--;
132
+ return s.slice(a, b);
133
+ }
134
+ /** The largest file a person (or the gallery) writes into a workspace or the
135
+ * library through the store: 512 KB. A folder tool's program is one file,
136
+ * and the gallery browser's passed 256 KB on 2026-09-29. */
137
+ export const MAX_EDITABLE_FILE = 512 * 1024;
138
+ /**
139
+ * Every entry under `dir`, relative, like `fs.readdirSync(dir, { recursive:
140
+ * true })` — except that a link to a directory is listed and NOT entered.
141
+ * Node's recursive readdir follows directory links, so a link back up the
142
+ * tree (the `workspace` link a step holds in its agent folder, confine.ts)
143
+ * is walked about forty levels deep before the kernel stops it, each level
144
+ * a full copy of the workspace.
145
+ */
146
+ export function entriesNoFollow(dir) {
147
+ const out = [];
148
+ const walk = (abs, rel) => {
149
+ let entries;
150
+ try {
151
+ entries = fs.readdirSync(abs, { withFileTypes: true });
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ for (const e of entries) {
157
+ const r = rel ? path.join(rel, e.name) : e.name;
158
+ out.push(r);
159
+ if (e.isDirectory())
160
+ walk(path.join(abs, e.name), r);
161
+ }
162
+ };
163
+ walk(dir, "");
164
+ return out;
165
+ }