@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.
- package/README.md +27 -4
- package/dist/internal/private-workerd-sandbox-worker.mjs +243 -273
- package/dist/internal/sandbox-metafile.json +35 -30
- package/dist/local-board-agents.d.ts +46 -0
- package/dist/local-board-agents.js +138 -0
- package/dist/local-board-shipping.d.ts +129 -0
- package/dist/local-board-shipping.js +325 -0
- package/dist/local-extensions.d.ts +4 -2
- package/dist/local-extensions.js +56 -22
- package/dist/local-login.js +33 -25
- package/dist/private-fungi-mcp-connection.js +6 -1
- package/dist/private-invocation-computer.d.ts +6 -2
- package/dist/private-invocation-computer.js +20 -13
- package/dist/private-local-agent-control.d.ts +5 -1
- package/dist/private-local-agent-control.js +11 -1
- package/dist/private-local-auth-rpc.d.ts +5 -0
- package/dist/private-local-auth-rpc.js +46 -4
- package/dist/private-local-client-version.js +3 -1
- package/dist/private-local-computer-processes.d.ts +7 -2
- package/dist/private-local-computer-processes.js +47 -23
- package/dist/private-local-delegation.d.ts +18 -0
- package/dist/private-local-delegation.js +84 -26
- package/dist/private-local-exec-environment.d.ts +5 -2
- package/dist/private-local-exec-environment.js +35 -1
- package/dist/private-local-exec-review.js +14 -7
- package/dist/private-local-exec-rules.d.ts +7 -0
- package/dist/private-local-exec-rules.js +23 -6
- package/dist/private-local-extension-records.d.ts +1 -1
- package/dist/private-local-runtime-host.js +54 -27
- package/dist/private-local-sandbox-policy.js +4 -0
- package/dist/private-local-sandbox.d.ts +3 -1
- package/dist/private-local-sandbox.js +2 -2
- package/dist/private-local-schedule-client.d.ts +12 -0
- package/dist/private-local-schedule-client.js +41 -0
- package/dist/private-local-schedules.d.ts +84 -0
- package/dist/private-local-schedules.js +65 -0
- package/dist/private-local-service-contract.d.ts +4 -0
- package/dist/private-local-service-discovery.d.ts +3 -3
- package/dist/private-local-service-discovery.js +1 -1
- package/dist/private-local-service-listener.js +9 -0
- package/dist/private-local-service-owner.d.ts +130 -0
- package/dist/private-local-service-owner.js +90 -2
- package/dist/private-local-service.d.ts +3 -0
- package/dist/private-local-service.js +9 -1
- package/dist/private-local-session-discovery.d.ts +10 -4
- package/dist/private-local-session-discovery.js +37 -4
- package/dist/private-provider-rejection.d.ts +1 -1
- package/dist/private-provider-rejection.js +28 -8
- package/dist/private-sqlite-owner.d.ts +7 -1
- package/dist/private-sqlite-owner.js +14 -2
- 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" | "
|
|
104
|
+
capabilities: ("state" | "prompt" | "attention" | "settled" | "send" | "timers" | "commands" | "projection" | "run_tools" | "files" | "compaction_handoff")[];
|
|
103
105
|
skills: boolean;
|
|
104
106
|
}>;
|
|
105
107
|
/**
|
package/dist/local-extensions.js
CHANGED
|
@@ -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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
},
|
package/dist/local-login.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
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
|
|
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
|
-
}
|
|
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.
|