@kevin5251984/guild 0.2.19 → 0.2.20

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.
@@ -0,0 +1,346 @@
1
+ import type { ModelReasoning } from "@guild/protocol";
2
+ import { guildUserAgent } from "./version.ts";
3
+
4
+ const MODELS_DEV = "https://models.dev/api.json";
5
+ const OPENROUTER_MODELS = "https://openrouter.ai/api/v1/models";
6
+ const TTL_MS = 6 * 60 * 60 * 1000;
7
+ const FETCH_MS = 4_000;
8
+
9
+ const DEV_PROVIDER: Record<string, string> = {
10
+ xai: "xai",
11
+ "xai-oauth": "xai",
12
+ openai: "openai",
13
+ "openai-codex": "openai",
14
+ anthropic: "anthropic",
15
+ "anthropic-oauth": "anthropic",
16
+ "github-copilot": "github-copilot",
17
+ "opencode-free": "opencode",
18
+ openrouter: "openrouter",
19
+ "openrouter-oauth": "openrouter",
20
+ };
21
+
22
+ const OPENROUTER_PREFIX: Record<string, string> = {
23
+ xai: "x-ai",
24
+ openai: "openai",
25
+ anthropic: "anthropic",
26
+ "opencode-free": "opencode",
27
+ "github-copilot": "github-copilot",
28
+ };
29
+
30
+ /** Sort key only — never used as the displayed list. */
31
+ const EFFORT_RANK = [
32
+ "none",
33
+ "minimal",
34
+ "low",
35
+ "medium",
36
+ "high",
37
+ "xhigh",
38
+ "max",
39
+ ];
40
+
41
+ type CatalogMaps = {
42
+ at: number;
43
+ dev: Map<string, ModelReasoning>;
44
+ openrouter: Map<string, ModelReasoning>;
45
+ gatewayEfforts: string[];
46
+ };
47
+
48
+ let maps: CatalogMaps | null = null;
49
+
50
+ export function parseEffortList(raw: unknown): string[] {
51
+ if (!Array.isArray(raw)) return [];
52
+ const out: string[] = [];
53
+ for (const item of raw) {
54
+ if (typeof item !== "string") continue;
55
+ const key = item.trim().toLowerCase();
56
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(key) || out.includes(key)) continue;
57
+ out.push(key);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ export function sanitizeEffort(raw: unknown): string | undefined {
63
+ if (typeof raw !== "string") return undefined;
64
+ const key = raw.trim().toLowerCase();
65
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(key)) return undefined;
66
+ return key;
67
+ }
68
+
69
+ export function fromModelsDevModel(raw: unknown): ModelReasoning | undefined {
70
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
71
+ const row = raw as Record<string, unknown>;
72
+ if (row.reasoning === false) return undefined;
73
+ const options = Array.isArray(row.reasoning_options)
74
+ ? row.reasoning_options
75
+ : [];
76
+ let efforts: string[] = [];
77
+ let supportsMaxTokens = false;
78
+ for (const opt of options) {
79
+ if (!opt || typeof opt !== "object" || Array.isArray(opt)) continue;
80
+ const rec = opt as Record<string, unknown>;
81
+ if (rec.type === "effort") efforts = parseEffortList(rec.values);
82
+ if (rec.type === "budget_tokens") supportsMaxTokens = true;
83
+ }
84
+ if (row.reasoning !== true && !efforts.length && !supportsMaxTokens) {
85
+ return undefined;
86
+ }
87
+ const spec: ModelReasoning = {};
88
+ if (efforts.length) spec.supportedEfforts = efforts;
89
+ if (supportsMaxTokens) spec.supportsMaxTokens = true;
90
+ spec.defaultEnabled = true;
91
+ if (efforts.length) spec.mandatory = !efforts.includes("none");
92
+ if (efforts.includes("high")) spec.defaultEffort = "high";
93
+ else if (efforts.includes("medium")) spec.defaultEffort = "medium";
94
+ else if (efforts.length) spec.defaultEffort = efforts[0];
95
+ return spec;
96
+ }
97
+
98
+ export function fromOpenRouterModel(raw: unknown): ModelReasoning | undefined {
99
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
100
+ const row = raw as Record<string, unknown>;
101
+ const block = row.reasoning;
102
+ if (block === undefined) return undefined;
103
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
104
+ return undefined;
105
+ }
106
+ const rec = block as Record<string, unknown>;
107
+ const spec: ModelReasoning = {};
108
+ if (rec.supported_efforts === null) {
109
+ spec.supportedEfforts = undefined;
110
+ } else {
111
+ const efforts = parseEffortList(rec.supported_efforts);
112
+ if (efforts.length) spec.supportedEfforts = efforts;
113
+ }
114
+ const def = sanitizeEffort(rec.default_effort);
115
+ if (def) spec.defaultEffort = def;
116
+ if (typeof rec.mandatory === "boolean") spec.mandatory = rec.mandatory;
117
+ if (typeof rec.default_enabled === "boolean") {
118
+ spec.defaultEnabled = rec.default_enabled;
119
+ }
120
+ if (rec.supports_max_tokens === true) spec.supportsMaxTokens = true;
121
+ if (
122
+ !spec.supportedEfforts &&
123
+ spec.defaultEffort === undefined &&
124
+ spec.mandatory === undefined &&
125
+ spec.supportsMaxTokens === undefined
126
+ ) {
127
+ return { defaultEnabled: spec.defaultEnabled ?? true };
128
+ }
129
+ return spec;
130
+ }
131
+
132
+ function indexDev(data: unknown): Map<string, ModelReasoning> {
133
+ const out = new Map<string, ModelReasoning>();
134
+ if (!data || typeof data !== "object") return out;
135
+ for (const [pid, prov] of Object.entries(data as Record<string, unknown>)) {
136
+ if (!prov || typeof prov !== "object") continue;
137
+ const models = (prov as { models?: Record<string, unknown> }).models;
138
+ if (!models || typeof models !== "object") continue;
139
+ for (const [mid, model] of Object.entries(models)) {
140
+ const spec = fromModelsDevModel(model);
141
+ if (!spec) continue;
142
+ out.set(`${pid}/${mid}`, spec);
143
+ const bare = mid.split("/").pop() || mid;
144
+ if (bare !== mid) out.set(`${pid}/${bare}`, spec);
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ function indexOpenRouter(data: unknown): {
151
+ map: Map<string, ModelReasoning>;
152
+ gateway: string[];
153
+ } {
154
+ const map = new Map<string, ModelReasoning>();
155
+ const seen = new Set<string>();
156
+ const rows = Array.isArray(data)
157
+ ? data
158
+ : data && typeof data === "object" && Array.isArray((data as { data?: unknown }).data)
159
+ ? ((data as { data: unknown[] }).data)
160
+ : [];
161
+ for (const row of rows) {
162
+ const spec = fromOpenRouterModel(row);
163
+ if (!spec) continue;
164
+ const id =
165
+ row && typeof row === "object" && typeof (row as { id?: unknown }).id === "string"
166
+ ? (row as { id: string }).id
167
+ : "";
168
+ if (!id) continue;
169
+ map.set(id, spec);
170
+ const bare = id.split("/").pop() || id;
171
+ if (bare !== id && !map.has(bare)) map.set(bare, spec);
172
+ for (const effort of spec.supportedEfforts ?? []) seen.add(effort);
173
+ }
174
+ const gateway = EFFORT_RANK.filter((key) => seen.has(key));
175
+ for (const key of seen) {
176
+ if (!gateway.includes(key)) gateway.push(key);
177
+ }
178
+ return { map, gateway };
179
+ }
180
+
181
+ export function setReasoningCatalogForTests(input: {
182
+ dev?: unknown;
183
+ openrouter?: unknown;
184
+ }): void {
185
+ const openrouter = indexOpenRouter(input.openrouter ?? { data: [] });
186
+ maps = {
187
+ at: Date.now(),
188
+ dev: indexDev(input.dev ?? {}),
189
+ openrouter: openrouter.map,
190
+ gatewayEfforts: openrouter.gateway,
191
+ };
192
+ }
193
+
194
+ export function resetReasoningCatalog(): void {
195
+ maps = null;
196
+ }
197
+
198
+ function skipLiveFetch(): boolean {
199
+ return Boolean(
200
+ process.env.NODE_TEST_CONTEXT ||
201
+ process.argv.some((arg) => arg === "--test" || arg.includes("--test=")),
202
+ );
203
+ }
204
+
205
+ export async function refreshReasoningCatalog(
206
+ force = false,
207
+ fetcher: typeof fetch = fetch,
208
+ ): Promise<void> {
209
+ if (!force && maps && Date.now() - maps.at < TTL_MS) return;
210
+ if (!force && skipLiveFetch() && maps) return;
211
+ if (skipLiveFetch() && !force) return;
212
+ const ctrl = AbortSignal.timeout(FETCH_MS);
213
+ const [devRes, orRes] = await Promise.allSettled([
214
+ fetcher(MODELS_DEV, {
215
+ headers: { accept: "application/json", "user-agent": guildUserAgent() },
216
+ signal: ctrl,
217
+ }),
218
+ fetcher(OPENROUTER_MODELS, {
219
+ headers: { accept: "application/json", "user-agent": guildUserAgent() },
220
+ signal: ctrl,
221
+ }),
222
+ ]);
223
+ let dev = maps?.dev ?? new Map<string, ModelReasoning>();
224
+ let openrouter = maps?.openrouter ?? new Map<string, ModelReasoning>();
225
+ let gateway = maps?.gatewayEfforts ?? [];
226
+ let got = false;
227
+ if (devRes.status === "fulfilled" && devRes.value.ok) {
228
+ try {
229
+ dev = indexDev(await devRes.value.json());
230
+ got = true;
231
+ } catch {
232
+ /* keep previous */
233
+ }
234
+ }
235
+ if (orRes.status === "fulfilled" && orRes.value.ok) {
236
+ try {
237
+ const indexed = indexOpenRouter(await orRes.value.json());
238
+ openrouter = indexed.map;
239
+ gateway = indexed.gateway;
240
+ got = true;
241
+ } catch {
242
+ /* keep previous */
243
+ }
244
+ }
245
+ if (!got && !maps) return;
246
+ if (!got) return;
247
+ maps = {
248
+ at: Date.now(),
249
+ dev,
250
+ openrouter,
251
+ gatewayEfforts: gateway,
252
+ };
253
+ }
254
+
255
+ function fillNullEfforts(spec: ModelReasoning): ModelReasoning {
256
+ if (spec.supportedEfforts?.length) return spec;
257
+ if (!maps?.gatewayEfforts.length) return spec;
258
+ return { ...spec, supportedEfforts: [...maps.gatewayEfforts] };
259
+ }
260
+
261
+ export function reasoningFor(
262
+ providerId: string,
263
+ modelId: string,
264
+ ): ModelReasoning | undefined {
265
+ if (!maps) return undefined;
266
+ const provider = String(providerId || "").trim().toLowerCase();
267
+ const model = String(modelId || "").trim();
268
+ if (!provider || !model) return undefined;
269
+ const bare = model.split("/").pop() || model;
270
+ const devId = DEV_PROVIDER[provider] || provider.replace(/-oauth$/, "");
271
+
272
+ if (devId === "openrouter") {
273
+ const hit =
274
+ maps.openrouter.get(model) ||
275
+ maps.openrouter.get(bare) ||
276
+ maps.openrouter.get(model.toLowerCase());
277
+ return hit ? fillNullEfforts(hit) : undefined;
278
+ }
279
+
280
+ const devHit =
281
+ maps.dev.get(`${devId}/${model}`) ||
282
+ maps.dev.get(`${devId}/${bare}`) ||
283
+ maps.dev.get(`${devId}/${bare.toLowerCase()}`);
284
+ if (devHit) return devHit;
285
+
286
+ const prefix = OPENROUTER_PREFIX[devId] || OPENROUTER_PREFIX[provider];
287
+ if (prefix) {
288
+ const slug = `${prefix}/${bare}`;
289
+ const orHit = maps.openrouter.get(slug) || maps.openrouter.get(model);
290
+ if (orHit) return fillNullEfforts(orHit);
291
+ }
292
+ return undefined;
293
+ }
294
+
295
+ export function attachReasoning(
296
+ providerId: string,
297
+ models: { id: string; name?: string; reasoning?: ModelReasoning }[],
298
+ ): { id: string; name?: string; reasoning?: ModelReasoning }[] {
299
+ return models.map((model) => {
300
+ const spec = reasoningFor(providerId, model.id);
301
+ if (!spec) return model;
302
+ return { ...model, reasoning: spec };
303
+ });
304
+ }
305
+
306
+ export function clampEffort(
307
+ want: string | undefined,
308
+ spec: ModelReasoning | undefined,
309
+ fast = false,
310
+ ): string | undefined {
311
+ const efforts = spec?.supportedEfforts;
312
+ if (!efforts?.length) return undefined;
313
+ const usable = spec?.mandatory
314
+ ? efforts.filter((key) => key !== "none")
315
+ : efforts;
316
+ if (!usable.length) return undefined;
317
+ if (fast) {
318
+ if (usable.includes("low")) return "low";
319
+ if (usable.includes("minimal")) return "minimal";
320
+ const ranked = EFFORT_RANK.filter((key) => usable.includes(key));
321
+ return ranked[0] || usable[usable.length - 1];
322
+ }
323
+ const picked = sanitizeEffort(want);
324
+ if (picked && usable.includes(picked)) return picked;
325
+ if (spec?.defaultEffort && usable.includes(spec.defaultEffort)) {
326
+ return spec.defaultEffort;
327
+ }
328
+ return usable.includes("high")
329
+ ? "high"
330
+ : usable.includes("medium")
331
+ ? "medium"
332
+ : usable[0];
333
+ }
334
+
335
+ export function reasoningPayload(
336
+ providerId: string,
337
+ baseUrl: string,
338
+ effort: string | undefined,
339
+ ): Record<string, unknown> {
340
+ if (!effort) return {};
341
+ const viaOpenRouter =
342
+ providerId.includes("openrouter") ||
343
+ /openrouter\.ai/i.test(baseUrl);
344
+ if (viaOpenRouter) return { reasoning: { effort } };
345
+ return { reasoning_effort: effort, reasoning: { effort } };
346
+ }
package/src/router.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  mergeModelsFile,
30
30
  publicModels,
31
31
  refreshOpenCodeFreeCatalog,
32
+ refreshReasoningCatalog,
32
33
  listBench,
33
34
  listLibrary,
34
35
  listMcpServers,
@@ -102,11 +103,107 @@ const LIBRARY_KINDS = new Set<LibraryKind>([
102
103
  "subagents",
103
104
  ]);
104
105
 
105
- const CORS_HEADERS = {
106
- "access-control-allow-origin": "*",
107
- "access-control-allow-methods": "GET, POST, PATCH, PUT, DELETE, OPTIONS",
108
- "access-control-allow-headers": "content-type, authorization",
109
- } as const;
106
+ /**
107
+ * Same-origin guard. The hall UI is served by this daemon (`http://127.0.0.1:7420/`
108
+ * and the Tailscale address), so it never needs CORS. A request that carries an
109
+ * `Origin` from somewhere else is a foreign page trying to read local files
110
+ * (`/host/read`), so it is refused before any route runs.
111
+ */
112
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
113
+ /** Tailscale MagicDNS names: `machine.tailnet.ts.net`. */
114
+ const MAGIC_DNS_SUFFIX = ".ts.net";
115
+
116
+ function headerLine(value: string | string[] | undefined): string {
117
+ const first = Array.isArray(value) ? value[0] : value;
118
+ return typeof first === "string" ? first.trim() : "";
119
+ }
120
+
121
+ function normalizeHost(host: string): string {
122
+ const lower = host.trim().toLowerCase().replace(/\.$/, "");
123
+ return LOCAL_HOSTS.has(lower) ? "127.0.0.1" : lower;
124
+ }
125
+
126
+ /** Host header is an authority, not a URL: `127.0.0.1:7420`, `[::1]:7420`, `bot.local`. */
127
+ function hostPortOf(authority: string): { host: string; port: string } {
128
+ const value = authority.trim();
129
+ if (!value) return { host: "", port: "" };
130
+ if (value.startsWith("[")) {
131
+ const end = value.indexOf("]");
132
+ const host = end === -1 ? value : value.slice(0, end + 1);
133
+ const rest = end === -1 ? "" : value.slice(end + 1);
134
+ const colon = rest.indexOf(":");
135
+ return { host, port: colon === -1 ? "" : rest.slice(colon + 1) };
136
+ }
137
+ const colon = value.indexOf(":");
138
+ if (colon === -1) return { host: value, port: "" };
139
+ return { host: value.slice(0, colon), port: value.slice(colon + 1) };
140
+ }
141
+
142
+ /** Dotted-quad octets, or null when this is not an IPv4 literal. */
143
+ function ipv4Octets(host: string): number[] | null {
144
+ const parts = host.split(".");
145
+ if (parts.length !== 4) return null;
146
+ const octets: number[] = [];
147
+ for (const part of parts) {
148
+ if (!/^\d{1,3}$/.test(part)) return null;
149
+ const value = Number(part);
150
+ if (!Number.isInteger(value) || value > 255) return null;
151
+ octets.push(value);
152
+ }
153
+ return octets;
154
+ }
155
+
156
+ /**
157
+ * True only for authorities that cannot be a public name: loopback, RFC1918,
158
+ * CGNAT / Tailscale 100.64/10, and MagicDNS `*.ts.net`. A rebinding page
159
+ * (`evil.com` resolving to 127.0.0.1) matches Origin against Host, so the
160
+ * authority itself has to be checked too.
161
+ */
162
+ export function isLocalAuthority(raw: string): boolean {
163
+ let host = String(raw || "").trim().toLowerCase().replace(/\.$/, "");
164
+ if (!host) return false;
165
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
166
+ if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
167
+ if (LOCAL_HOSTS.has(host)) return true;
168
+ const octets = ipv4Octets(host);
169
+ if (octets) {
170
+ const [first, second] = octets as [number, number, number, number];
171
+ if (first === 127) return true; // 127/8 loopback
172
+ if (first === 10) return true; // RFC1918
173
+ if (first === 172 && second >= 16 && second <= 31) return true;
174
+ if (first === 192 && second === 168) return true;
175
+ if (first === 100 && second >= 64 && second <= 127) return true; // CGNAT
176
+ return false;
177
+ }
178
+ return host.endsWith(MAGIC_DNS_SUFFIX);
179
+ }
180
+
181
+ /** True when there is no Origin (curl, Node fetch, same-origin opaque) or it matches Host. */
182
+ export function sameOrigin(req: IncomingMessage): boolean {
183
+ const raw = headerLine(req.headers.origin);
184
+ if (!raw) return true;
185
+ let origin: URL;
186
+ try {
187
+ origin = new URL(raw);
188
+ } catch {
189
+ return false;
190
+ }
191
+ const secure = origin.protocol === "https:";
192
+ if (!secure && origin.protocol !== "http:") return false;
193
+ const host = headerLine(req.headers.host);
194
+ if (!host) return false;
195
+ const wanted = hostPortOf(host);
196
+ // Origin == Host is not enough: DNS rebinding makes a public name resolve to
197
+ // this machine, so both authorities must be loopback / private.
198
+ if (!isLocalAuthority(wanted.host) || !isLocalAuthority(origin.hostname)) {
199
+ return false;
200
+ }
201
+ return (
202
+ normalizeHost(wanted.host) === normalizeHost(origin.hostname) &&
203
+ (wanted.port || (secure ? "443" : "80")) ===
204
+ (origin.port || (secure ? "443" : "80"))
205
+ );
206
+ }
110
207
 
111
208
  function send(
112
209
  res: ServerResponse,
@@ -119,7 +216,6 @@ function send(
119
216
  "content-type": type,
120
217
  "content-length": Buffer.byteLength(body),
121
218
  "cache-control": "no-store",
122
- ...CORS_HEADERS,
123
219
  ...extra,
124
220
  });
125
221
  res.end(body);
@@ -261,8 +357,12 @@ export async function handleRequest(
261
357
  const path = pathname(req);
262
358
 
263
359
  try {
360
+ if (!sameOrigin(req)) {
361
+ throw new StoreError(403, "cross-origin refused");
362
+ }
363
+
264
364
  if (method === "OPTIONS") {
265
- res.writeHead(204, CORS_HEADERS);
365
+ res.writeHead(204);
266
366
  res.end();
267
367
  return;
268
368
  }
@@ -311,7 +411,6 @@ export async function handleRequest(
311
411
  "content-type": type,
312
412
  "content-length": bytes.length,
313
413
  "cache-control": "private, max-age=86400",
314
- ...CORS_HEADERS,
315
414
  });
316
415
  res.end(bytes);
317
416
  return;
@@ -859,6 +958,7 @@ export async function handleRequest(
859
958
  if (method === "GET" && path === "/settings/models") {
860
959
  await refreshCopilotCatalog(store.dataDir);
861
960
  await refreshOpenCodeFreeCatalog(store.dataDir);
961
+ await refreshReasoningCatalog().catch(() => {});
862
962
  json(res, 200, publicModels(store.dataDir));
863
963
  return;
864
964
  }
package/src/store.ts CHANGED
@@ -26,7 +26,11 @@ import { DEFAULT_BOTS } from "./catalog/default-bots.ts";
26
26
  import { CATALOG_SKILLS } from "./catalog/skills.ts";
27
27
  import { CATALOG_SUBAGENTS } from "./catalog/subagents.ts";
28
28
  import { parseAgentFile } from "./agent-file.ts";
29
- import { parseMentionIds, sanitizeMentionIds } from "./mention.ts";
29
+ import {
30
+ parseMentionIds,
31
+ sanitizeMentionIds,
32
+ withoutDeferredIds,
33
+ } from "./mention.ts";
30
34
 
31
35
  const MARKDOWN: Record<LibraryKind, string> = {
32
36
  souls: "SOUL.md",
@@ -989,11 +993,14 @@ export class GuildStore {
989
993
  const now = new Date().toISOString();
990
994
  const startedAt = author !== "you" ? usage?.startedAt : undefined;
991
995
  const bots = this.listBots();
992
- const mentionIds = (
993
- mentions !== undefined
996
+ const mentionIds = withoutDeferredIds(
997
+ (mentions !== undefined
994
998
  ? sanitizeMentionIds(mentions, bots)
995
999
  : parseMentionIds(text, bots, author === "you" ? "user" : "bot")
996
- ).filter((id) => id !== author);
1000
+ ).filter((id) => id !== author),
1001
+ text,
1002
+ bots,
1003
+ );
997
1004
  const message: ChatMessage = {
998
1005
  id: randomUUID(),
999
1006
  roomId,
@@ -1022,10 +1029,12 @@ export class GuildStore {
1022
1029
  const text = body.trim();
1023
1030
  if (!text) throw new StoreError(400, "message is required");
1024
1031
  const bots = this.listBots();
1025
- const mentionIds = (
1032
+ const mentionIds = withoutDeferredIds(
1026
1033
  mentions !== undefined
1027
1034
  ? sanitizeMentionIds(mentions, bots)
1028
- : parseMentionIds(text, bots, "user")
1035
+ : parseMentionIds(text, bots, "user"),
1036
+ text,
1037
+ bots,
1029
1038
  );
1030
1039
  const next = this.db.updateMessageBody(roomId, messageId, text, mentionIds);
1031
1040
  if (!next) throw new StoreError(404, "message not found");
package/src/subagent.ts CHANGED
@@ -73,16 +73,21 @@ function agentKey(value: string): string {
73
73
  return value.trim().replace(/^\/+/, "").toLowerCase();
74
74
  }
75
75
 
76
- /** Parent read_only cannot escalate via spawn. Explorer from full_access keeps run. */
76
+ /**
77
+ * A child never outruns its parent, and a read-only agent never gets `run`
78
+ * back: a readOnly child is pinned to read_only even under a full_access
79
+ * parent (that used to be an escalation hole — CHILD_TOOLS_RO advertised run
80
+ * and gateTool let a full_access child through it).
81
+ */
77
82
  export function childSpawnPolicy(
78
83
  parentSandbox: ToolContext["sandbox"],
79
84
  agentReadOnly: boolean,
80
85
  ): { sandbox: Sandbox; allowWrite: boolean } {
81
86
  const parent = parseSandbox(parentSandbox);
82
- if (parent === "read_only") {
87
+ if (parent === "read_only" || agentReadOnly) {
83
88
  return { sandbox: "read_only", allowWrite: false };
84
89
  }
85
- return { sandbox: parent, allowWrite: !agentReadOnly };
90
+ return { sandbox: parent, allowWrite: true };
86
91
  }
87
92
 
88
93
  export function resolveSubagent(
@@ -108,8 +113,8 @@ Never say you cannot access this machine. Check [exit code: N] on every run.
108
113
  Independent searches: emit multiple tool calls in one round; they run in parallel.`;
109
114
 
110
115
  const CHILD_TOOLS_RO = `You ARE already running on the user's local computer (Guild).
111
- Tools: run, read, list, skill. You cannot write files and cannot spawn subagents.
112
- Read-only. Never edit, patch, or create files. Check [exit code: N] on every run.
116
+ Tools: read, list, skill. You cannot run shell commands, cannot write files, and cannot spawn subagents.
117
+ Read-only. Never edit, patch, or create files.
113
118
  Independent searches: emit multiple tool calls in one round; they run in parallel.`;
114
119
 
115
120
  export const SPAWN_MAX_PARALLEL = 8;
package/src/tools.ts CHANGED
@@ -7,6 +7,7 @@ import { Type, type Tool } from "@earendil-works/pi-ai";
7
7
  import { listHostSkills } from "./host-skills.ts";
8
8
  import type { McpToolRef } from "./mcp.ts";
9
9
  import {
10
+ defaultWorkspace,
10
11
  gateTool,
11
12
  parseSandbox,
12
13
  resolveToolPath,
@@ -186,7 +187,9 @@ export function guildTools(
186
187
  (tool) => tool.name === "read" || tool.name === "list",
187
188
  );
188
189
  } else if (sandbox === "workspace_write") {
189
- tools = tools.filter((tool) => tool.name !== "image_gen");
190
+ tools = tools.filter(
191
+ (tool) => tool.name !== "image_gen" && tool.name !== "browser",
192
+ );
190
193
  }
191
194
  tools.push({
192
195
  name: "skill",
@@ -465,9 +468,11 @@ export async function builtinExecute(
465
468
  ctx: ToolContext = {},
466
469
  ): Promise<ToolOutcome> {
467
470
  try {
471
+ // workspace_write resolves relative paths from the workspace root, which
472
+ // defaults to the guild checkout (same root gateTool checks against).
468
473
  const pathBase =
469
- parseSandbox(ctx.sandbox) === "workspace_write" && ctx.workspace
470
- ? resolveToolPath(ctx.workspace)
474
+ parseSandbox(ctx.sandbox) === "workspace_write"
475
+ ? resolveToolPath(ctx.workspace?.trim() || defaultWorkspace())
471
476
  : HOME;
472
477
  if (name === "run") {
473
478
  return await runCommand(
package/src/version.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /**
4
+ * Outbound User-Agent. npm ships this package's source, so the manifest always
5
+ * sits one level above `src/` — same lookup as `--version` in cli.ts.
6
+ */
7
+ export function guildUserAgent(): string {
8
+ return `Guild/${guildVersion()}`;
9
+ }
10
+
11
+ let cached: string | null = null;
12
+
13
+ export function guildVersion(): string {
14
+ if (cached !== null) return cached;
15
+ try {
16
+ const pkg = JSON.parse(
17
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
18
+ ) as { version?: unknown };
19
+ const version = typeof pkg.version === "string" ? pkg.version.trim() : "";
20
+ cached = version || "0";
21
+ } catch {
22
+ cached = "0";
23
+ }
24
+ return cached;
25
+ }
@@ -133,9 +133,20 @@ export type LlmApi =
133
133
  | "anthropic-messages"
134
134
  | "openai-responses";
135
135
 
136
+ export type ModelReasoning = {
137
+ /** Effort strings this model accepts, catalog order. Missing = no effort picker. */
138
+ supportedEfforts?: string[];
139
+ defaultEffort?: string;
140
+ /** When true, do not send `none` — reasoning cannot be turned off. */
141
+ mandatory?: boolean;
142
+ defaultEnabled?: boolean;
143
+ supportsMaxTokens?: boolean;
144
+ };
145
+
136
146
  export type ModelEntry = {
137
147
  id: string;
138
148
  name?: string;
149
+ reasoning?: ModelReasoning;
139
150
  };
140
151
 
141
152
  export type ProviderEntry = {
@@ -158,7 +169,8 @@ export type AuxRole =
158
169
 
159
170
  export type ModelsFile = {
160
171
  default?: ModelRef | null;
161
- reasoning?: "minimal" | "low" | "medium" | "high";
172
+ /** Last chosen effort string (catalog-defined: low, high, xhigh, …). */
173
+ reasoning?: string;
162
174
  fast?: boolean;
163
175
  aux?: Partial<Record<AuxRole, ModelRef | null>>;
164
176
  recent?: ModelRef[];