@fungi.computer/truffle 0.4.0 → 0.6.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 (51) hide show
  1. package/README.md +27 -4
  2. package/dist/internal/private-workerd-sandbox-worker.mjs +243 -273
  3. package/dist/internal/sandbox-metafile.json +35 -30
  4. package/dist/local-board-agents.d.ts +46 -0
  5. package/dist/local-board-agents.js +138 -0
  6. package/dist/local-board-shipping.d.ts +129 -0
  7. package/dist/local-board-shipping.js +325 -0
  8. package/dist/local-extensions.d.ts +4 -2
  9. package/dist/local-extensions.js +56 -22
  10. package/dist/local-login.js +33 -25
  11. package/dist/private-fungi-mcp-connection.js +6 -1
  12. package/dist/private-invocation-computer.d.ts +6 -2
  13. package/dist/private-invocation-computer.js +20 -13
  14. package/dist/private-local-agent-control.d.ts +5 -1
  15. package/dist/private-local-agent-control.js +11 -1
  16. package/dist/private-local-auth-rpc.d.ts +5 -0
  17. package/dist/private-local-auth-rpc.js +46 -4
  18. package/dist/private-local-client-version.js +3 -1
  19. package/dist/private-local-computer-processes.d.ts +7 -2
  20. package/dist/private-local-computer-processes.js +47 -23
  21. package/dist/private-local-delegation.d.ts +18 -0
  22. package/dist/private-local-delegation.js +84 -26
  23. package/dist/private-local-exec-environment.d.ts +5 -2
  24. package/dist/private-local-exec-environment.js +35 -1
  25. package/dist/private-local-exec-review.js +14 -7
  26. package/dist/private-local-exec-rules.d.ts +7 -0
  27. package/dist/private-local-exec-rules.js +23 -6
  28. package/dist/private-local-extension-records.d.ts +1 -1
  29. package/dist/private-local-runtime-host.js +54 -27
  30. package/dist/private-local-sandbox-policy.js +4 -0
  31. package/dist/private-local-sandbox.d.ts +3 -1
  32. package/dist/private-local-sandbox.js +2 -2
  33. package/dist/private-local-schedule-client.d.ts +12 -0
  34. package/dist/private-local-schedule-client.js +41 -0
  35. package/dist/private-local-schedules.d.ts +84 -0
  36. package/dist/private-local-schedules.js +65 -0
  37. package/dist/private-local-service-contract.d.ts +4 -0
  38. package/dist/private-local-service-discovery.d.ts +3 -3
  39. package/dist/private-local-service-discovery.js +1 -1
  40. package/dist/private-local-service-listener.js +9 -0
  41. package/dist/private-local-service-owner.d.ts +130 -0
  42. package/dist/private-local-service-owner.js +90 -2
  43. package/dist/private-local-service.d.ts +3 -0
  44. package/dist/private-local-service.js +9 -1
  45. package/dist/private-local-session-discovery.d.ts +10 -4
  46. package/dist/private-local-session-discovery.js +37 -4
  47. package/dist/private-provider-rejection.d.ts +1 -1
  48. package/dist/private-provider-rejection.js +28 -8
  49. package/dist/private-sqlite-owner.d.ts +7 -1
  50. package/dist/private-sqlite-owner.js +14 -2
  51. package/package.json +29 -16
@@ -0,0 +1,325 @@
1
+ import { execFile } from "node:child_process";
2
+ import { open } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import * as Result from "effect/Result";
5
+ import * as Schema from "effect/Schema";
6
+ import * as SchemaIssue from "effect/SchemaIssue";
7
+ /** What happened to a piece of work: it landed, shipped out, or is on its way. */
8
+ export const ShipKind = Schema.Literals([
9
+ "landed",
10
+ "released",
11
+ "queued",
12
+ "gating",
13
+ "dropped",
14
+ ]);
15
+ const text = Schema.String.check(Schema.isMaxLength(200));
16
+ /** One thing a shipping source saw since local midnight. */
17
+ export const ShipItem = Schema.Struct({
18
+ /** Stable within its source. */
19
+ id: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)),
20
+ /** What happened to it. */
21
+ kind: ShipKind,
22
+ /** A one-line title, such as a commit subject or a released version. */
23
+ title: text,
24
+ /** The branch, tag or package it belongs to. */
25
+ ref: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256))),
26
+ /** When it happened. */
27
+ at: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
28
+ /** A short note the source adds. */
29
+ detail: Schema.optionalKey(text),
30
+ });
31
+ /** The most shipping sources one board reads. */
32
+ export const maximumShippingSources = 6;
33
+ const reference = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
34
+ /** Commits first-parent on `ref` since midnight; a protected branch's merges are its landings. */
35
+ const gitSourceConfig = Schema.Struct({
36
+ /** Reads a Git history. */
37
+ kind: Schema.Literal("git"),
38
+ /** The branch, tag or `HEAD` whose first-parent history is read. */
39
+ ref: reference,
40
+ /** Whether those commits landed work or released it. */
41
+ shows: Schema.Literals(["landed", "released"]),
42
+ });
43
+ /** Versions of these packages published today. */
44
+ const npmSourceConfig = Schema.Struct({
45
+ /** Reads a package registry. */
46
+ kind: Schema.Literal("npm"),
47
+ /** Package names, as `npm view` takes them. */
48
+ packages: Schema.Array(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(214))).check(Schema.isMinLength(1), Schema.isMaxLength(8)),
49
+ });
50
+ /** One configured shipping source; an unknown kind or key is refused. */
51
+ export const ShippingSourceConfig = Schema.Union([
52
+ gitSourceConfig,
53
+ npmSourceConfig,
54
+ ]);
55
+ /** The `board` key of a `.fungi/settings.json`; excess keys are refused. */
56
+ export const BoardSettings = Schema.Struct({
57
+ /** The sources the Board reads for what shipped today. */
58
+ shipping: Schema.optionalKey(Schema.Array(ShippingSourceConfig).check(Schema.isMaxLength(maximumShippingSources))),
59
+ });
60
+ const isKey = Schema.is(Schema.Union([Schema.String, Schema.Finite]));
61
+ /** The sources a settings `board` value names, or each refusal with its settings path. */
62
+ export function decodeBoardSettings(board) {
63
+ const decoded = Schema.decodeUnknownResult(BoardSettings, {
64
+ onExcessProperty: "error",
65
+ errors: "all",
66
+ })(board);
67
+ if (Result.isSuccess(decoded))
68
+ return Result.succeed(decoded.success.shipping ?? []);
69
+ const format = SchemaIssue.makeFormatterStandardSchemaV1();
70
+ return Result.fail(format(decoded.failure.issue).issues.map(({ path, message }) => (path ?? []).reduce((where, part) => {
71
+ // Settings are JSON, so every path segment is a name or an index.
72
+ const key = isKey(part) ? String(part) : "?";
73
+ return /^\d+$/u.test(key) ? `${where}[${key}]` : `${where}.${key}`;
74
+ }, "board") + `: ${message}`));
75
+ }
76
+ /** A settings file larger than this is not read. */
77
+ const maximumSettingsBytes = 64 * 1024;
78
+ /** Git history is read at most this many commits deep; the board lists far fewer. */
79
+ const maximumCommits = 200;
80
+ const gitRefreshMs = 60_000;
81
+ const npmRefreshMs = 5 * 60_000;
82
+ /** How long the answer to "is this a work tree, and on which branch" is kept. */
83
+ const defaultBranchMs = 60_000;
84
+ /** The first line a program said, so a refusal reads as a reason and not as a command line. */
85
+ function reason(file, stderr, cause) {
86
+ const line = stderr.split("\n").find((text) => text.trim() !== "");
87
+ if ("code" in cause && cause.code === "ENOENT")
88
+ return `${file} is not installed`;
89
+ return `${file} failed${line === undefined ? "" : `: ${line.trim()}`}`;
90
+ }
91
+ /**
92
+ * The tool's ordinary interface: its own argv, no shell, a bounded environment.
93
+ * A program that is missing or refuses is an error with a short reason.
94
+ */
95
+ export const execShipping = (file, args, { cwd, signal }) => new Promise((resolve, reject) => {
96
+ execFile(file, [...args], {
97
+ cwd,
98
+ signal,
99
+ maxBuffer: 4 * 1024 * 1024,
100
+ env: {
101
+ ...process.env,
102
+ GIT_TERMINAL_PROMPT: "0",
103
+ GIT_OPTIONAL_LOCKS: "0",
104
+ LC_ALL: "C",
105
+ npm_config_update_notifier: "false",
106
+ npm_config_fund: "false",
107
+ npm_config_audit: "false",
108
+ },
109
+ }, (error, stdout, stderr) => {
110
+ if (error === null)
111
+ resolve(stdout);
112
+ else
113
+ reject(new Error(reason(file, stderr, error)));
114
+ });
115
+ });
116
+ const decodeItems = Schema.decodeUnknownSync(Schema.Array(ShipItem));
117
+ /**
118
+ * Commits first-parent on `ref` since local midnight, from `git log`. A merge
119
+ * shows the subject of the tip it merged, and any other commit its own, so a
120
+ * merge queue's "Merge branch ..." lines read as the work that landed.
121
+ */
122
+ export function gitSource(config, options) {
123
+ const { ref, shows } = config;
124
+ return {
125
+ id: `git:${ref}:${shows}`,
126
+ label: options.label ?? ref,
127
+ refreshMs: gitRefreshMs,
128
+ read: async (sinceMs, signal) => {
129
+ const run = (args) => options.exec("git", args, { cwd: options.cwd, signal });
130
+ const log = await run([
131
+ "log",
132
+ "--first-parent",
133
+ `--since=${new Date(sinceMs).toISOString()}`,
134
+ `--max-count=${String(maximumCommits)}`,
135
+ "--format=%H%x1f%ct%x1f%s%x1f%P%x1e",
136
+ ref,
137
+ "--",
138
+ ]);
139
+ const commits = records(log, 4).map(([hash, time, subject, parents]) => ({
140
+ hash: hash ?? "",
141
+ time: Number(time),
142
+ subject: subject ?? "",
143
+ merged: (parents ?? "").split(" ").filter((parent) => parent !== "")[1],
144
+ }));
145
+ const tips = [
146
+ ...new Set(commits.flatMap(({ merged }) => merged === undefined ? [] : [merged])),
147
+ ];
148
+ const tipSubjects = new Map();
149
+ if (tips.length > 0) {
150
+ const shown = await run([
151
+ "log",
152
+ "--no-walk=unsorted",
153
+ "--format=%H%x1f%s%x1e",
154
+ ...tips,
155
+ "--",
156
+ ]);
157
+ for (const [hash, subject] of records(shown, 2))
158
+ if (hash !== undefined)
159
+ tipSubjects.set(hash, subject ?? "");
160
+ }
161
+ const items = decodeItems(commits.map(({ hash, time, subject, merged }) => ({
162
+ id: hash.slice(0, 12),
163
+ kind: shows,
164
+ title: (merged === undefined
165
+ ? subject
166
+ : (tipSubjects.get(merged) ?? subject)).slice(0, 200),
167
+ ref,
168
+ at: time * 1000,
169
+ })));
170
+ return { state: "ok", items: items.filter((item) => item.at >= sinceMs) };
171
+ },
172
+ };
173
+ }
174
+ /** Split a program's `%x1e`-terminated, `%x1f`-separated records into fields. */
175
+ function records(output, fields) {
176
+ return output
177
+ .split("\x1e")
178
+ .map((record) => record.replace(/^\n+/u, ""))
179
+ .filter((record) => record !== "")
180
+ .map((record) => record.split("\x1f", fields));
181
+ }
182
+ const publishTimes = Schema.Record(Schema.String, Schema.String);
183
+ const decodePublishTimes = Schema.decodeUnknownSync(publishTimes);
184
+ /** Keys of `npm view time` that are not versions. */
185
+ const notVersions = new Set(["created", "modified"]);
186
+ /** Versions of each package published since local midnight, from `npm view <package> time --json`. */
187
+ export function npmSource(config, options) {
188
+ const { packages } = config;
189
+ return {
190
+ id: `npm:${packages.join(",")}`,
191
+ label: `npm: ${packages.join(", ")}`,
192
+ refreshMs: npmRefreshMs,
193
+ read: async (sinceMs, signal) => {
194
+ const found = [];
195
+ for (const name of packages) {
196
+ const times = decodePublishTimes(JSON.parse(await options.exec("npm", ["view", name, "time", "--json"], {
197
+ cwd: options.cwd,
198
+ signal,
199
+ })));
200
+ for (const [version, published] of Object.entries(times)) {
201
+ const at = Date.parse(published);
202
+ if (!notVersions.has(version) && at >= sinceMs)
203
+ found.push({
204
+ id: `${name}@${version}`,
205
+ kind: "released",
206
+ title: `${name} ${version}`,
207
+ ref: name,
208
+ at,
209
+ });
210
+ }
211
+ }
212
+ return {
213
+ state: "ok",
214
+ items: decodeItems(found.sort((left, right) => right.at - left.at)),
215
+ };
216
+ },
217
+ };
218
+ }
219
+ /** A source that only says why the settings could not be used. */
220
+ function settingsProblem(file, message) {
221
+ return {
222
+ id: `settings:${file}`,
223
+ label: "Board settings",
224
+ refreshMs: gitRefreshMs,
225
+ read: () => Promise.reject(new Error(`${file}: ${message}`)),
226
+ };
227
+ }
228
+ /** Read one settings file's `board` key; a missing file or key says nothing. */
229
+ async function readSettings(path) {
230
+ let text;
231
+ try {
232
+ const handle = await open(path, "r");
233
+ try {
234
+ const bytes = Buffer.alloc(maximumSettingsBytes + 1);
235
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
236
+ if (bytesRead > maximumSettingsBytes)
237
+ return {
238
+ kind: "refused",
239
+ problems: ["the file is larger than 64 KiB"],
240
+ };
241
+ text = bytes.subarray(0, bytesRead).toString("utf8");
242
+ }
243
+ finally {
244
+ await handle.close();
245
+ }
246
+ }
247
+ catch (cause) {
248
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
249
+ return { kind: "none" };
250
+ return {
251
+ kind: "refused",
252
+ problems: [cause instanceof Error ? cause.message : "could not be read"],
253
+ };
254
+ }
255
+ let parsed;
256
+ try {
257
+ parsed = JSON.parse(text);
258
+ }
259
+ catch {
260
+ return { kind: "refused", problems: ["settings are not valid JSON"] };
261
+ }
262
+ const board = Schema.decodeUnknownOption(Schema.Struct({ board: Schema.optionalKey(Schema.Unknown) }))(parsed);
263
+ if (board._tag === "None" || board.value.board === undefined)
264
+ return { kind: "none" };
265
+ const decoded = decodeBoardSettings(board.value.board);
266
+ return decoded._tag === "Success"
267
+ ? { kind: "sources", sources: decoded.success }
268
+ : { kind: "refused", problems: decoded.failure };
269
+ }
270
+ /**
271
+ * The Board's shipping sources for this folder: `board.shipping` in the
272
+ * folder's `.fungi/settings.json`, then the person's, read afresh each cycle so
273
+ * an edit applies. A refused file is one failed source line naming its
274
+ * path, never a silent default. With nothing configured, a Git work tree
275
+ * shows its current branch as landed work, and a folder that is not one shows
276
+ * no shipping at all.
277
+ */
278
+ export function createLocalShippingSources(options) {
279
+ const exec = options.exec ?? execShipping;
280
+ const now = options.now ?? Date.now;
281
+ let branch;
282
+ /** The current branch when this folder is a Git work tree; asked at most once a minute. */
283
+ async function currentBranch(signal) {
284
+ if (branch !== undefined && now() - branch.at < defaultBranchMs)
285
+ return branch.name;
286
+ let name;
287
+ try {
288
+ name = (await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
289
+ cwd: options.cwd,
290
+ signal,
291
+ })).trim();
292
+ }
293
+ catch {
294
+ // Not a work tree, or no Git: there is nothing to default to.
295
+ name = null;
296
+ }
297
+ branch = { at: now(), name: name === "" ? null : name };
298
+ return branch.name;
299
+ }
300
+ return async (signal) => {
301
+ const files = [
302
+ join(options.cwd, ".fungi/settings.json"),
303
+ join(options.home, ".fungi/settings.json"),
304
+ ];
305
+ const configs = [];
306
+ const problems = [];
307
+ for (const file of files) {
308
+ const read = await readSettings(file);
309
+ if (read.kind === "sources")
310
+ configs.push(...read.sources);
311
+ else if (read.kind === "refused")
312
+ problems.push(settingsProblem(file, read.problems.join("; ")));
313
+ }
314
+ const context = { cwd: options.cwd, exec };
315
+ const sources = configs.map((config) => config.kind === "git"
316
+ ? gitSource(config, context)
317
+ : npmSource(config, context));
318
+ if (sources.length === 0 && problems.length === 0) {
319
+ const name = await currentBranch(signal);
320
+ if (name !== null)
321
+ sources.push(gitSource({ kind: "git", ref: "HEAD", shows: "landed" }, { ...context, label: name }));
322
+ }
323
+ return [...sources, ...problems];
324
+ };
325
+ }
@@ -41,6 +41,8 @@ export type LocalExtensionTerminal = Readonly<{
41
41
  terminalId: string;
42
42
  name: string;
43
43
  description: string;
44
+ /** A shell key that opens this program beside the focused pane. */
45
+ binding?: string;
44
46
  /** Absolute executable followed by its arguments. */
45
47
  command: readonly string[];
46
48
  cwd: string;
@@ -76,7 +78,7 @@ export declare function localExtensions(options: LocalExtensionsOptions): {
76
78
  * is pinned to the commit Bun's lockfile resolved; updating is another
77
79
  * explicit `add`. Missing declared artifacts are a refusal.
78
80
  */
79
- add(source: string): Promise<LocalInstalledExtension>;
81
+ add(source: string, cwd: string): Promise<LocalInstalledExtension>;
80
82
  /** Remove the package and every activation that references it. */
81
83
  remove(id: string): Promise<boolean>;
82
84
  list(): Promise<readonly LocalExtensionSummary[]>;
@@ -99,7 +101,7 @@ export declare function localExtensions(options: LocalExtensionsOptions): {
99
101
  agentStoreDirectory: string;
100
102
  }>): Promise<{
101
103
  revision: string;
102
- capabilities: ("state" | "attention" | "settled" | "send" | "timers" | "commands" | "projection" | "run_tools" | "files" | "prompt" | "compaction_handoff")[];
104
+ capabilities: ("state" | "prompt" | "attention" | "settled" | "send" | "timers" | "commands" | "projection" | "run_tools" | "files" | "compaction_handoff")[];
103
105
  skills: boolean;
104
106
  }>;
105
107
  /**
@@ -6,6 +6,7 @@ import { Mycelium } from "@fungi.computer/mycelium";
6
6
  import { defineExtension, ExtensionId, } from "@fungi.computer/shiitake/extensions";
7
7
  import * as Predicate from "effect/Predicate";
8
8
  import * as Schema from "effect/Schema";
9
+ import npa from "npm-package-arg";
9
10
  import writeFileAtomic from "write-file-atomic";
10
11
  import { z } from "zod";
11
12
  import { describeExtensionSkills, } from "./local-skill-source.js";
@@ -37,6 +38,11 @@ const manifestSchema = z.strictObject({
37
38
  id: identifier.max(64),
38
39
  name: z.string().trim().min(1).max(64),
39
40
  description: z.string().max(200).default(""),
41
+ /** A shell key that opens this program beside the focused pane. */
42
+ binding: z
43
+ .string()
44
+ .regex(/^<leader>[a-z]$/u)
45
+ .optional(),
40
46
  /** Package-relative executable and its arguments. */
41
47
  command: z
42
48
  .array(z.string().min(1).max(1_024))
@@ -60,6 +66,7 @@ const terminalRecord = z.strictObject({
60
66
  id: z.string(),
61
67
  name: z.string(),
62
68
  description: z.string(),
69
+ binding: z.string().optional(),
63
70
  command: z.array(z.string()),
64
71
  });
65
72
  const revisionRecord = z.strictObject({
@@ -331,29 +338,52 @@ export function localExtensions(options) {
331
338
  * is pinned to the commit Bun's lockfile resolved; updating is another
332
339
  * explicit `add`. Missing declared artifacts are a refusal.
333
340
  */
334
- async add(source) {
341
+ async add(source, cwd) {
342
+ if (source.length === 0)
343
+ throw new LocalExtensionError("install", "An extension source is required");
344
+ // Bun installs in private staging, not the caller's project. Let the
345
+ // maintained package-spec parser resolve only local sources there first;
346
+ // leave Git and registry specifications to Bun unchanged.
347
+ const parsed = npa(source, cwd);
348
+ const installSource = parsed.type === "directory" || parsed.type === "file"
349
+ ? z.string().parse(parsed.fetchSpec)
350
+ : source;
335
351
  await mkdir(join(root, "staging"), { recursive: true, mode: 0o700 });
336
352
  const staging = await mkdtemp(join(root, "staging", "install-"));
337
353
  try {
354
+ const runBun = async (args, directory) => {
355
+ const child = Bun.spawn([process.execPath, ...args], {
356
+ cwd: directory,
357
+ // A private cache resolves the named ref afresh instead of a
358
+ // cached checkout of an earlier commit.
359
+ env: {
360
+ ...process.env,
361
+ BUN_BE_BUN: "1",
362
+ BUN_INSTALL_CACHE_DIR: join(staging, ".install-cache"),
363
+ },
364
+ stdout: "ignore",
365
+ stderr: "pipe",
366
+ });
367
+ const [status, stderr] = await Promise.all([
368
+ child.exited,
369
+ new Response(child.stderr).text(),
370
+ ]);
371
+ if (status !== 0)
372
+ throw new LocalExtensionError("install", `Could not install ${source}: ${stderr.trim().slice(0, 2_000)}`);
373
+ };
374
+ // Bun links a directory dependency's files back to its source, even
375
+ // with --backend=copyfile. Its ordinary pack operation supplies an
376
+ // isolated package snapshot and the install lock supplies its integrity.
377
+ // Both operations keep lifecycle scripts disabled.
378
+ const archive = join(staging, "source.tgz");
379
+ if (parsed.type === "directory")
380
+ await runBun(["pm", "pack", "--ignore-scripts", "--filename", archive], installSource);
338
381
  await writeFile(join(staging, "package.json"), `${JSON.stringify({ private: true })}\n`);
339
- const child = Bun.spawn([process.execPath, "add", "--ignore-scripts", source], {
340
- cwd: staging,
341
- // A private cache resolves the named ref afresh instead of a
342
- // cached checkout of an earlier commit.
343
- env: {
344
- ...process.env,
345
- BUN_BE_BUN: "1",
346
- BUN_INSTALL_CACHE_DIR: join(staging, ".install-cache"),
347
- },
348
- stdout: "pipe",
349
- stderr: "pipe",
350
- });
351
- const [status, stderr] = await Promise.all([
352
- child.exited,
353
- new Response(child.stderr).text(),
354
- ]);
355
- if (status !== 0)
356
- throw new LocalExtensionError("install", `Could not install ${source}: ${stderr.trim().slice(0, 2_000)}`);
382
+ await runBun([
383
+ "add",
384
+ "--ignore-scripts",
385
+ parsed.type === "directory" ? archive : installSource,
386
+ ], staging);
357
387
  const lock = lockSchema.parse(Bun.JSONC.parse(await readFile(join(staging, "bun.lock"), "utf8")));
358
388
  const dependencies = Object.keys(z
359
389
  .looseObject({ dependencies: z.record(z.string(), z.string()) })
@@ -614,8 +644,8 @@ export function localExtensions(options) {
614
644
  if (record.client !== null)
615
645
  clients.push(record.client);
616
646
  const runtime = await runtimeDirectory(project, id);
617
- for (const terminal of record.terminals)
618
- terminals.push({
647
+ for (const terminal of record.terminals) {
648
+ const launcher = {
619
649
  extensionId: id,
620
650
  terminalId: terminal.id,
621
651
  name: terminal.name,
@@ -630,7 +660,11 @@ export function localExtensions(options) {
630
660
  FUNGI_PROJECT_ROOT: project,
631
661
  FUNGI_EXTENSION_RUNTIME_DIR: runtime,
632
662
  },
633
- });
663
+ };
664
+ terminals.push(terminal.binding === undefined
665
+ ? launcher
666
+ : { ...launcher, binding: terminal.binding });
667
+ }
634
668
  }
635
669
  return { clients, terminals, diagnostics };
636
670
  },
@@ -1,3 +1,4 @@
1
+ import { interactionTitleLimit } from "@fungi.computer/whistle/wire";
1
2
  /** Display order for popular model providers; labels remain owned by Pi. */
2
3
  export const localPopularProviderIds = Object.freeze([
3
4
  "fungi",
@@ -54,13 +55,32 @@ function notice(event) {
54
55
  return { type: "progress", message: event.message };
55
56
  }
56
57
  }
58
+ /**
59
+ * Pi's prompt text is provider wording, not a modal title: Whistle titles are
60
+ * at most `interactionTitleLimit` characters, and Pi 1.3's Anthropic paste
61
+ * prompt is longer. A message that fits stays the title; a longer one keeps
62
+ * every word as the summary under Fungi's fixed title for that prompt kind,
63
+ * so a provider sentence can never make the dialog refuse to open.
64
+ */
65
+ function modalText(message, fallbackTitle, hint) {
66
+ const fits = message.trim() !== "" && message.length <= interactionTitleLimit;
67
+ const lines = [
68
+ ...(fits ? [] : [message]),
69
+ ...(hint === undefined ? [] : [hint]),
70
+ ];
71
+ const title = fits ? message : fallbackTitle;
72
+ return lines.length === 0
73
+ ? { message: title }
74
+ : { message: title, summary: lines.join(" ") };
75
+ }
76
+ const placeholderOf = (placeholder) => placeholder === undefined ? {} : { placeholder };
57
77
  async function ask(interaction, prompt) {
58
78
  const options = prompt.signal === undefined ? {} : { signal: prompt.signal };
59
79
  switch (prompt.type) {
60
80
  case "select":
61
81
  return interaction.prompt({
62
82
  type: "choice",
63
- message: prompt.message,
83
+ ...modalText(prompt.message, "Choose a sign-in option"),
64
84
  options: prompt.options.map((option) => option.description === undefined
65
85
  ? { value: option.id, label: option.label }
66
86
  : {
@@ -69,36 +89,24 @@ async function ask(interaction, prompt) {
69
89
  description: option.description,
70
90
  }),
71
91
  }, options);
72
- case "manual_code": {
92
+ case "manual_code":
73
93
  // Generic across every provider that falls back to a pasted code or
74
94
  // redirect URL (OpenAI, Anthropic): the provider's own
75
95
  // message explains what to paste; this adds the SSH hint once, here,
76
96
  // instead of baking it into one provider's registry label.
77
- const summary = "Connecting over SSH? Paste the URL here instead of opening a browser.";
78
- const { message } = prompt;
79
- return interaction.prompt(prompt.placeholder === undefined
80
- ? { type: "text", message, summary }
81
- : {
82
- type: "text",
83
- message,
84
- summary,
85
- placeholder: prompt.placeholder,
86
- }, options);
87
- }
97
+ return interaction.prompt({
98
+ type: "text",
99
+ ...modalText(prompt.message, "Paste the sign-in code or redirect URL", "Connecting over SSH? Paste the URL here instead of opening a browser."),
100
+ ...placeholderOf(prompt.placeholder),
101
+ }, options);
88
102
  case "secret":
89
103
  case "text":
90
- return interaction.prompt(prompt.placeholder === undefined
91
- ? {
92
- type: "text",
93
- message: prompt.message,
94
- secret: prompt.type === "secret",
95
- }
96
- : {
97
- type: "text",
98
- message: prompt.message,
99
- placeholder: prompt.placeholder,
100
- secret: prompt.type === "secret",
101
- }, options);
104
+ return interaction.prompt({
105
+ type: "text",
106
+ ...modalText(prompt.message, prompt.type === "secret" ? "Enter your key" : "Enter a value"),
107
+ ...placeholderOf(prompt.placeholder),
108
+ secret: prompt.type === "secret",
109
+ }, options);
102
110
  }
103
111
  }
104
112
  const methodNotOffered = "The provider did not offer that sign-in method";
@@ -2,6 +2,7 @@ import { FungiSignInRequiredError } from "./private-fungi-auth-error.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { Client, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
4
4
  import { Mycelium } from "@fungi.computer/mycelium";
5
+ import { forageDiscoveryInstructions } from "@fungi.computer/mycelium/effect";
5
6
  import { z } from "zod";
6
7
  import { parseFungiMcpEndpoint } from "./private-fungi-mcp-endpoint.js";
7
8
  import { registerMcpFormElicitation, McpFormApprovalError, } from "./private-mcp-form-elicitation.js";
@@ -83,7 +84,11 @@ export function createPrivateFungiMcpConnection(options) {
83
84
  operations: {
84
85
  execute: Mycelium.operation({
85
86
  replay: "unsafe",
86
- description: "Execute JavaScript on the connected Fungi MCP server. Discover permitted Team Computer operations with await forage.search({query: 'read'}) and await forage.describe({path: 'files.read'}). Explicitly return the result. Returns the original MCP content and isError; it does not run on the local machine.",
87
+ description: [
88
+ "This operation sends its code to the connected Fungi MCP server's execute tool. The following discovery instructions apply inside that remote call, not the outer Local call.",
89
+ forageDiscoveryInstructions,
90
+ "Explicitly return the result. Returns the original MCP content and isError; it does not run on the local machine.",
91
+ ].join(" "),
87
92
  input: z.strictObject({ code: z.string() }),
88
93
  execute: async (input, context) => {
89
94
  try {
@@ -21,6 +21,7 @@ declare const execInput: z.ZodUnion<readonly [z.ZodObject<{
21
21
  justification: z.ZodString;
22
22
  }, z.core.$strict>>;
23
23
  cwd: z.ZodOptional<z.ZodString>;
24
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
24
25
  timeoutMs: z.ZodDefault<z.ZodNumber>;
25
26
  }, z.core.$strict>, z.ZodObject<{
26
27
  executable: z.ZodString;
@@ -31,6 +32,7 @@ declare const execInput: z.ZodUnion<readonly [z.ZodObject<{
31
32
  justification: z.ZodString;
32
33
  }, z.core.$strict>>;
33
34
  cwd: z.ZodOptional<z.ZodString>;
35
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
34
36
  timeoutMs: z.ZodDefault<z.ZodNumber>;
35
37
  }, z.core.$strict>]>;
36
38
  declare const completedOutput: z.ZodObject<{
@@ -247,7 +249,9 @@ export type LocalExecPolicyRun = (input: Parameters<LocalExecEscapeStage>[0]) =>
247
249
  /** Run the recorded command through the normal bounded process door, without a sandbox.
248
250
  * The host supplies the captured PATH and canonical cwd; the record supplies the
249
251
  * exact original request. No model invocation can call this door directly. */
250
- export declare function dispatchApprovedLocalExec(input: unknown, cwd: string, executionPath: string, home: string, processes: PrivateLocalProcessScope, mode: LocalPermissionMode): Promise<ExecOutput | z.output<typeof backgroundExecOutput>>;
252
+ export declare function dispatchApprovedLocalExec(input: unknown, cwd: string, executionPath: string, home: string, processes: PrivateLocalProcessScope, mode: LocalPermissionMode,
253
+ /** Only inline rule matches have a live execute sink; ledger dispatch does not. */
254
+ sink?: InvocationContext["output"]): Promise<ExecOutput | z.output<typeof backgroundExecOutput>>;
251
255
  export type ExecOutput = z.output<typeof execOutput>;
252
256
  export type ProcessOutput = z.output<typeof completedOutput> | z.output<typeof deadlineOutput>;
253
257
  /**
@@ -262,7 +266,7 @@ export declare function runPrivateInvocationProcess(processCommand: ChildProcess
262
266
  requestedMs?: number;
263
267
  /** The real command text, for the timeout message; omitted when not meaningful (e.g. a native helper). */
264
268
  describeCommand?: () => string;
265
- }>): Promise<ProcessOutput>;
269
+ }>, output?: InvocationContext["output"]): Promise<ProcessOutput>;
266
270
  /**
267
271
  * Construct one private invocation-root Computer: bounded shell exec, plus the
268
272
  * background-process operations of `processes` when the host supplies a scope.