@fungi.computer/truffle 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +294 -2
- package/computerd-loopback.cjs +16 -0
- package/dist/auth-cli.d.ts +36 -0
- package/dist/auth-cli.js +351 -0
- package/dist/fungi-account-command.d.ts +18 -0
- package/dist/fungi-account-command.js +92 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/internal/private-workerd-sandbox-worker.mjs +27488 -0
- package/dist/internal/sandbox-metafile.json +6726 -0
- package/dist/linear-auth.d.ts +4 -0
- package/dist/linear-auth.js +72 -0
- package/dist/local-extensions.d.ts +97 -0
- package/dist/local-extensions.js +484 -0
- package/dist/local-login.d.ts +55 -0
- package/dist/local-login.js +167 -0
- package/dist/local-models.d.ts +14 -0
- package/dist/local-models.js +77 -0
- package/dist/local-skill-source.d.ts +6 -0
- package/dist/local-skill-source.js +357 -0
- package/dist/log-event.d.ts +2 -0
- package/dist/log-event.js +9 -0
- package/dist/private-bun-shiitake-host.d.ts +11 -0
- package/dist/private-bun-shiitake-host.js +66 -0
- package/dist/private-file-credential-store.d.ts +33 -0
- package/dist/private-file-credential-store.js +283 -0
- package/dist/private-files-patch.d.ts +3 -0
- package/dist/private-files-patch.js +86 -0
- package/dist/private-fungi-account.d.ts +235 -0
- package/dist/private-fungi-account.js +1149 -0
- package/dist/private-fungi-auth-error.d.ts +5 -0
- package/dist/private-fungi-auth-error.js +7 -0
- package/dist/private-fungi-mcp-connection.d.ts +22 -0
- package/dist/private-fungi-mcp-connection.js +143 -0
- package/dist/private-fungi-mcp-endpoint.d.ts +2 -0
- package/dist/private-fungi-mcp-endpoint.js +15 -0
- package/dist/private-fungi-mcp-selection.d.ts +14 -0
- package/dist/private-fungi-mcp-selection.js +36 -0
- package/dist/private-fungi-model-api.d.ts +6 -0
- package/dist/private-fungi-model-api.js +125 -0
- package/dist/private-fungi-model-catalog.d.ts +6 -0
- package/dist/private-fungi-model-catalog.js +78 -0
- package/dist/private-fungi-model-provider.d.ts +6 -0
- package/dist/private-fungi-model-provider.js +46 -0
- package/dist/private-fungi-pending-logouts.d.ts +34 -0
- package/dist/private-fungi-pending-logouts.js +136 -0
- package/dist/private-invocation-computer.d.ts +37 -0
- package/dist/private-invocation-computer.js +459 -0
- package/dist/private-invocation-files.d.ts +15 -0
- package/dist/private-invocation-files.js +449 -0
- package/dist/private-local-acp-stdio.d.ts +17 -0
- package/dist/private-local-acp-stdio.js +142 -0
- package/dist/private-local-auth-rpc.d.ts +69 -0
- package/dist/private-local-auth-rpc.js +273 -0
- package/dist/private-local-computer-attach.d.ts +49 -0
- package/dist/private-local-computer-attach.js +216 -0
- package/dist/private-local-computer-backend.d.ts +282 -0
- package/dist/private-local-computer-backend.js +402 -0
- package/dist/private-local-computer-processes.d.ts +199 -0
- package/dist/private-local-computer-processes.js +589 -0
- package/dist/private-local-default-model.d.ts +11 -0
- package/dist/private-local-default-model.js +45 -0
- package/dist/private-local-delegation.d.ts +29 -0
- package/dist/private-local-delegation.js +78 -0
- package/dist/private-local-effective-model.d.ts +19 -0
- package/dist/private-local-effective-model.js +14 -0
- package/dist/private-local-exec-launch.d.ts +41 -0
- package/dist/private-local-exec-launch.js +51 -0
- package/dist/private-local-exec-path.d.ts +6 -0
- package/dist/private-local-exec-path.js +40 -0
- package/dist/private-local-git-exclude.d.ts +2 -0
- package/dist/private-local-git-exclude.js +33 -0
- package/dist/private-local-http-dispatch.d.ts +5 -0
- package/dist/private-local-http-dispatch.js +14 -0
- package/dist/private-local-mail.d.ts +11 -0
- package/dist/private-local-mail.js +69 -0
- package/dist/private-local-model-settings.d.ts +5 -0
- package/dist/private-local-model-settings.js +31 -0
- package/dist/private-local-runtime-host.d.ts +141 -0
- package/dist/private-local-runtime-host.js +988 -0
- package/dist/private-local-service-contract.d.ts +90 -0
- package/dist/private-local-service-contract.js +1 -0
- package/dist/private-local-service-discovery.d.ts +101 -0
- package/dist/private-local-service-discovery.js +221 -0
- package/dist/private-local-service-ids.d.ts +3 -0
- package/dist/private-local-service-ids.js +8 -0
- package/dist/private-local-service-listener.d.ts +15 -0
- package/dist/private-local-service-listener.js +211 -0
- package/dist/private-local-service-owner.d.ts +1567 -0
- package/dist/private-local-service-owner.js +371 -0
- package/dist/private-local-service-process.d.ts +1 -0
- package/dist/private-local-service-process.js +6 -0
- package/dist/private-local-service.d.ts +17 -0
- package/dist/private-local-service.js +330 -0
- package/dist/private-local-session-discovery.d.ts +19 -0
- package/dist/private-local-session-discovery.js +157 -0
- package/dist/private-local-shiitake-host.d.ts +44 -0
- package/dist/private-local-shiitake-host.js +143 -0
- package/dist/private-local-subagents.d.ts +27 -0
- package/dist/private-local-subagents.js +77 -0
- package/dist/private-local-worker-server.d.ts +15 -0
- package/dist/private-local-worker-server.js +136 -0
- package/dist/private-sqlite-owner.d.ts +7 -0
- package/dist/private-sqlite-owner.js +103 -0
- package/dist/private-team-computers.d.ts +38 -0
- package/dist/private-team-computers.js +101 -0
- package/dist/private-workerd-sandbox.d.ts +3 -0
- package/dist/private-workerd-sandbox.js +347 -0
- package/dist/team-apps.d.ts +24 -0
- package/dist/team-apps.js +123 -0
- package/dist/tracker-git-credential.d.ts +15 -0
- package/dist/tracker-git-credential.js +87 -0
- package/native/bin/build.json +4037 -0
- package/native/bin/fungi-apply-patch +0 -0
- package/package.json +148 -3
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync, readSync, writeSync } from "node:fs";
|
|
3
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { defineIdFamily } from "@fungi.computer/cairn";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { combinedArgvBytes, launchCwdSchema, launchStdinSchema, maximumLaunchTextLength, } from "./private-local-exec-launch.js";
|
|
11
|
+
/**
|
|
12
|
+
* Background processes of the local Computer: the one owner of "a command that
|
|
13
|
+
* outlives a single exec call".
|
|
14
|
+
*
|
|
15
|
+
* `exec` kills its whole process group when its call returns, so `nohup … &`
|
|
16
|
+
* cannot survive it. A process started here instead belongs to one scope (a
|
|
17
|
+
* Session, or the invocation when the host has no Sessions) of one host. It
|
|
18
|
+
* runs in its own process group with stdout and stderr kept in one bounded
|
|
19
|
+
* circular log under the project's `.fungi/processes/`, and it ends when it exits, when
|
|
20
|
+
* `kill` is called, when its scope is disposed (Session deleted), or when the
|
|
21
|
+
* host closes. The shell/exec surface owns start, poll, wait, list and kill.
|
|
22
|
+
*/
|
|
23
|
+
/** Running processes one scope (Session) may hold at once. */
|
|
24
|
+
const maximumRunningProcessesPerScope = 8;
|
|
25
|
+
/** Running processes one host may hold at once; mirrors the container Computer. */
|
|
26
|
+
const maximumRunningProcesses = 16;
|
|
27
|
+
/** Records, running or exited, one scope keeps; the oldest exited is evicted. */
|
|
28
|
+
const maximumProcessRecordsPerScope = 32;
|
|
29
|
+
/** Bytes of combined stdout and stderr one process log keeps. */
|
|
30
|
+
const maximumLogBytes = 8 * 1_024 * 1_024;
|
|
31
|
+
/** Bytes one read returns at most. */
|
|
32
|
+
const maximumReadBytes = 64 * 1_024;
|
|
33
|
+
const defaultReadBytes = 16 * 1_024;
|
|
34
|
+
/** One wait returns after at most this long; call it again to keep waiting. */
|
|
35
|
+
const maximumWaitMs = 60_000;
|
|
36
|
+
const defaultWaitMs = 10_000;
|
|
37
|
+
/** SIGTERM grace before SIGKILL when a process group is stopped. */
|
|
38
|
+
const terminationGraceMs = 2_000;
|
|
39
|
+
const localProcessIds = defineIdFamily({
|
|
40
|
+
family: "local-computer-process",
|
|
41
|
+
version: 1,
|
|
42
|
+
kinds: { process: { prefix: "local_process" } },
|
|
43
|
+
});
|
|
44
|
+
const processIdSchema = z
|
|
45
|
+
.string()
|
|
46
|
+
.max(128)
|
|
47
|
+
.refine((id) => localProcessIds.process.is(id), "Unknown process id");
|
|
48
|
+
const processStateSchema = z.enum(["running", "exited"]);
|
|
49
|
+
const processSchema = z.strictObject({
|
|
50
|
+
id: processIdSchema,
|
|
51
|
+
command: z.string(),
|
|
52
|
+
cwd: z.string(),
|
|
53
|
+
state: processStateSchema,
|
|
54
|
+
exitCode: z.number().int().nullable(),
|
|
55
|
+
signal: z.string().nullable(),
|
|
56
|
+
startedAt: z.number().int(),
|
|
57
|
+
endedAt: z.number().int().nullable(),
|
|
58
|
+
logPath: z.string(),
|
|
59
|
+
logBytes: z.number().int().nonnegative(),
|
|
60
|
+
logStartOffset: z.number().int().nonnegative(),
|
|
61
|
+
logTruncated: z.boolean(),
|
|
62
|
+
});
|
|
63
|
+
/** The intentional shell form of background `start`. */
|
|
64
|
+
const shellStartInput = z.strictObject({
|
|
65
|
+
operation: z.literal("start"),
|
|
66
|
+
command: z.string().min(1).max(maximumLaunchTextLength),
|
|
67
|
+
stdin: launchStdinSchema.optional(),
|
|
68
|
+
cwd: launchCwdSchema.optional(),
|
|
69
|
+
});
|
|
70
|
+
/** The direct form of background `start`: argv run with no shell interpretation. */
|
|
71
|
+
const directStartInput = z
|
|
72
|
+
.strictObject({
|
|
73
|
+
operation: z.literal("start"),
|
|
74
|
+
executable: z.string().min(1),
|
|
75
|
+
args: z.array(z.string()).default([]),
|
|
76
|
+
stdin: launchStdinSchema.optional(),
|
|
77
|
+
cwd: launchCwdSchema.optional(),
|
|
78
|
+
})
|
|
79
|
+
.refine((value) => combinedArgvBytes(value.executable, value.args) <=
|
|
80
|
+
maximumLaunchTextLength, {
|
|
81
|
+
message: `Computer exec executable and args exceed ${String(maximumLaunchTextLength)} combined UTF-8 bytes`,
|
|
82
|
+
path: ["args"],
|
|
83
|
+
});
|
|
84
|
+
const idInput = z.strictObject({ id: processIdSchema });
|
|
85
|
+
const readInput = z.strictObject({
|
|
86
|
+
id: processIdSchema,
|
|
87
|
+
offset: z.number().int().nonnegative().default(0),
|
|
88
|
+
maxBytes: z
|
|
89
|
+
.number()
|
|
90
|
+
.int()
|
|
91
|
+
.min(1)
|
|
92
|
+
.max(maximumReadBytes)
|
|
93
|
+
.default(defaultReadBytes),
|
|
94
|
+
});
|
|
95
|
+
const waitInput = z.strictObject({
|
|
96
|
+
id: processIdSchema,
|
|
97
|
+
timeoutMs: z.number().int().min(1).max(maximumWaitMs).default(defaultWaitMs),
|
|
98
|
+
});
|
|
99
|
+
const listOutput = z.strictObject({
|
|
100
|
+
processes: z.array(processSchema).max(maximumProcessRecordsPerScope),
|
|
101
|
+
});
|
|
102
|
+
const readOutput = z.strictObject({
|
|
103
|
+
process: processSchema,
|
|
104
|
+
offset: z.number().int().nonnegative(),
|
|
105
|
+
nextOffset: z.number().int().nonnegative(),
|
|
106
|
+
text: z.string(),
|
|
107
|
+
complete: z.boolean(),
|
|
108
|
+
});
|
|
109
|
+
const waitOutput = z.strictObject({
|
|
110
|
+
process: processSchema,
|
|
111
|
+
timedOut: z.boolean(),
|
|
112
|
+
});
|
|
113
|
+
const killOutput = z.strictObject({
|
|
114
|
+
process: processSchema,
|
|
115
|
+
signalled: z.boolean(),
|
|
116
|
+
});
|
|
117
|
+
/** No process with this id was started in this scope. */
|
|
118
|
+
class PrivateLocalProcessNotFoundError extends Error {
|
|
119
|
+
constructor(id) {
|
|
120
|
+
super(`Computer process not found: ${id}`);
|
|
121
|
+
this.name = "PrivateLocalProcessNotFoundError";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Every process slot of this scope or host is occupied. */
|
|
125
|
+
class PrivateLocalProcessLimitError extends Error {
|
|
126
|
+
constructor(limit, owner) {
|
|
127
|
+
super(`This ${owner} can run at most ${String(limit)} background processes at once; wait for or kill one with exec first`);
|
|
128
|
+
this.name = "PrivateLocalProcessLimitError";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Signal a whole process group; false when no member could be signalled. */
|
|
132
|
+
function signalGroup(groupId, signal) {
|
|
133
|
+
try {
|
|
134
|
+
process.kill(-groupId, signal);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error instanceof Error &&
|
|
139
|
+
"code" in error &&
|
|
140
|
+
(error.code === "ESRCH" || error.code === "EPERM"))
|
|
141
|
+
return false;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function groupAlive(groupId) {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(-groupId, 0);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch (cause) {
|
|
151
|
+
if (cause instanceof Error && "code" in cause) {
|
|
152
|
+
if (cause.code === "ESRCH")
|
|
153
|
+
return false;
|
|
154
|
+
if (cause.code === "EPERM")
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
throw cause;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function toCommandSpec(request) {
|
|
161
|
+
if ("command" in request) {
|
|
162
|
+
const spec = { kind: "shell", command: request.command };
|
|
163
|
+
return request.stdin === undefined
|
|
164
|
+
? spec
|
|
165
|
+
: { ...spec, stdin: request.stdin };
|
|
166
|
+
}
|
|
167
|
+
const spec = {
|
|
168
|
+
kind: "direct",
|
|
169
|
+
executable: request.executable,
|
|
170
|
+
args: request.args,
|
|
171
|
+
};
|
|
172
|
+
return request.stdin === undefined ? spec : { ...spec, stdin: request.stdin };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A one-line, human-readable label for a spec; never used for execution.
|
|
176
|
+
* Each direct-form arg is JSON-quoted so `list`/`poll` output does not read
|
|
177
|
+
* like a re-runnable shell line.
|
|
178
|
+
*/
|
|
179
|
+
function describeCommandSpec(spec) {
|
|
180
|
+
return spec.kind === "shell"
|
|
181
|
+
? spec.command
|
|
182
|
+
: [spec.executable, ...spec.args.map((arg) => JSON.stringify(arg))].join(" ");
|
|
183
|
+
}
|
|
184
|
+
function isErrnoException(error) {
|
|
185
|
+
return "code" in error;
|
|
186
|
+
}
|
|
187
|
+
/** Drop a trailing partial UTF-8 sequence so a later read resumes on it. */
|
|
188
|
+
function completeUtf8Length(bytes, length) {
|
|
189
|
+
for (let back = 1; back <= Math.min(4, length); back += 1) {
|
|
190
|
+
const byte = bytes[length - back] ?? 0;
|
|
191
|
+
if ((byte & 0xc0) === 0x80)
|
|
192
|
+
continue;
|
|
193
|
+
if (byte < 0xc2 || byte > 0xf4)
|
|
194
|
+
return length;
|
|
195
|
+
const expected = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : 2;
|
|
196
|
+
return back < expected ? length - back : length;
|
|
197
|
+
}
|
|
198
|
+
return length;
|
|
199
|
+
}
|
|
200
|
+
class ManagedProcess {
|
|
201
|
+
spec;
|
|
202
|
+
cwd;
|
|
203
|
+
id = localProcessIds.process.generate();
|
|
204
|
+
startedAt = Date.now();
|
|
205
|
+
logPath;
|
|
206
|
+
closed;
|
|
207
|
+
#child;
|
|
208
|
+
#groupId;
|
|
209
|
+
#fd;
|
|
210
|
+
#logBytes = 0;
|
|
211
|
+
#logTruncated = false;
|
|
212
|
+
#exitCode = null;
|
|
213
|
+
#signal = null;
|
|
214
|
+
#endedAt = null;
|
|
215
|
+
#leaderExited = false;
|
|
216
|
+
command;
|
|
217
|
+
constructor(spec, cwd, logDirectory, executionPath) {
|
|
218
|
+
this.spec = spec;
|
|
219
|
+
this.cwd = cwd;
|
|
220
|
+
this.command = describeCommandSpec(spec);
|
|
221
|
+
this.logPath = join(logDirectory, `${this.id}.log`);
|
|
222
|
+
this.#fd = openSync(this.logPath, "wx+", 0o600);
|
|
223
|
+
const closed = Promise.withResolvers();
|
|
224
|
+
this.closed = closed.promise;
|
|
225
|
+
const env = { PATH: executionPath, HOME: process.env.HOME ?? homedir() };
|
|
226
|
+
try {
|
|
227
|
+
// Its own process group, so neither exec's finalizer nor this host's
|
|
228
|
+
// terminal signals reach it; kill() owns ending the whole group.
|
|
229
|
+
this.#child =
|
|
230
|
+
spec.kind === "shell"
|
|
231
|
+
? spawn("sh", ["-c", spec.command], {
|
|
232
|
+
cwd,
|
|
233
|
+
env,
|
|
234
|
+
detached: true,
|
|
235
|
+
stdio: [
|
|
236
|
+
spec.stdin === undefined ? "ignore" : "pipe",
|
|
237
|
+
"pipe",
|
|
238
|
+
"pipe",
|
|
239
|
+
],
|
|
240
|
+
})
|
|
241
|
+
: spawn(spec.executable, spec.args, {
|
|
242
|
+
cwd,
|
|
243
|
+
env,
|
|
244
|
+
detached: true,
|
|
245
|
+
stdio: [
|
|
246
|
+
spec.stdin === undefined ? "ignore" : "pipe",
|
|
247
|
+
"pipe",
|
|
248
|
+
"pipe",
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
// Finite stdin, delivered once and closed; never an interactive
|
|
252
|
+
// protocol. `end()` writes the bytes and closes the write side. A
|
|
253
|
+
// child that exits or closes its own stdin before the payload drains
|
|
254
|
+
// (`true`, `head -c1`, an early exit) makes that write EPIPE; without
|
|
255
|
+
// this listener the stream emits 'error' with no handler and crashes
|
|
256
|
+
// the host process. There is no caller to reject here (`start` has
|
|
257
|
+
// already returned a process id), so this only records what happened.
|
|
258
|
+
if (spec.stdin !== undefined) {
|
|
259
|
+
const stdin = spec.stdin;
|
|
260
|
+
this.#child.stdin?.on("error", (error) => {
|
|
261
|
+
const code = isErrnoException(error) ? error.code : undefined;
|
|
262
|
+
this.#append(Buffer.from(code === "EPIPE"
|
|
263
|
+
? "\n[fungi: stdin not fully consumed]\n"
|
|
264
|
+
: `\n[fungi: stdin failed: ${error.message}]\n`));
|
|
265
|
+
});
|
|
266
|
+
this.#child.stdin?.end(Buffer.from(stdin, "utf8"));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (cause) {
|
|
270
|
+
closeSync(this.#fd);
|
|
271
|
+
throw cause;
|
|
272
|
+
}
|
|
273
|
+
const pid = this.#child.pid;
|
|
274
|
+
this.#groupId =
|
|
275
|
+
pid !== undefined && Number.isSafeInteger(pid) && pid > 1
|
|
276
|
+
? pid
|
|
277
|
+
: undefined;
|
|
278
|
+
const append = (chunk) => {
|
|
279
|
+
this.#append(chunk);
|
|
280
|
+
};
|
|
281
|
+
this.#child.stdout?.on("data", append);
|
|
282
|
+
this.#child.stderr?.on("data", append);
|
|
283
|
+
this.#child.on("error", (error) => {
|
|
284
|
+
this.#append(Buffer.from(`\n[fungi: process failed: ${error.message}]\n`));
|
|
285
|
+
this.#finish(closed.resolve);
|
|
286
|
+
});
|
|
287
|
+
this.#child.on("exit", (code, signal) => {
|
|
288
|
+
this.#leaderExited = true;
|
|
289
|
+
this.#exitCode = code;
|
|
290
|
+
this.#signal = signal;
|
|
291
|
+
// The command is finished; members it left in its group are not owned
|
|
292
|
+
// by anyone else, so end them and release the log pipes.
|
|
293
|
+
void this.#terminateGroup().then(() => {
|
|
294
|
+
this.#child.stdout?.destroy();
|
|
295
|
+
this.#child.stderr?.destroy();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
this.#child.on("close", () => {
|
|
299
|
+
this.#finish(closed.resolve);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
get running() {
|
|
303
|
+
return this.#endedAt === null;
|
|
304
|
+
}
|
|
305
|
+
#append(chunk) {
|
|
306
|
+
const fd = this.#fd;
|
|
307
|
+
if (fd === undefined)
|
|
308
|
+
return;
|
|
309
|
+
// Circular storage retains the latest bytes without growing the log or
|
|
310
|
+
// copying megabytes on every append. Offsets count all produced bytes.
|
|
311
|
+
const skipped = Math.max(0, chunk.byteLength - maximumLogBytes);
|
|
312
|
+
const kept = chunk.subarray(skipped);
|
|
313
|
+
const position = (this.#logBytes + skipped) % maximumLogBytes;
|
|
314
|
+
const first = Math.min(kept.byteLength, maximumLogBytes - position);
|
|
315
|
+
writeSync(fd, kept, 0, first, position);
|
|
316
|
+
if (first < kept.byteLength)
|
|
317
|
+
writeSync(fd, kept, first, kept.byteLength - first, 0);
|
|
318
|
+
this.#logBytes += chunk.byteLength;
|
|
319
|
+
this.#logTruncated = this.#logBytes > maximumLogBytes;
|
|
320
|
+
}
|
|
321
|
+
/** Read atomically with appends, including a wrap in circular storage. */
|
|
322
|
+
read(offset, maxBytes) {
|
|
323
|
+
const end = this.#logBytes;
|
|
324
|
+
let from = Math.max(Math.max(0, end - maximumLogBytes), Math.min(offset, end));
|
|
325
|
+
const length = Math.min(maxBytes, end - from);
|
|
326
|
+
const bytes = new Uint8Array(length);
|
|
327
|
+
const fd = this.#fd ?? openSync(this.logPath, "r");
|
|
328
|
+
try {
|
|
329
|
+
const position = from % maximumLogBytes;
|
|
330
|
+
const first = Math.min(length, maximumLogBytes - position);
|
|
331
|
+
if (first > 0)
|
|
332
|
+
readSync(fd, bytes, 0, first, position);
|
|
333
|
+
if (first < length)
|
|
334
|
+
readSync(fd, bytes, first, length - first, 0);
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
if (this.#fd === undefined)
|
|
338
|
+
closeSync(fd);
|
|
339
|
+
}
|
|
340
|
+
// A tail or evicted cursor may start inside a UTF-8 sequence.
|
|
341
|
+
let skipped = 0;
|
|
342
|
+
while (skipped < length && ((bytes[skipped] ?? 0) & 0xc0) === 0x80)
|
|
343
|
+
skipped += 1;
|
|
344
|
+
from += skipped;
|
|
345
|
+
const content = bytes.subarray(skipped);
|
|
346
|
+
const snapshot = this.snapshot();
|
|
347
|
+
// At the final byte of an exited command no continuation can arrive;
|
|
348
|
+
// flush malformed/incomplete terminal bytes through the decoder instead.
|
|
349
|
+
const kept = snapshot.state === "exited" && from + content.length === end
|
|
350
|
+
? content.length
|
|
351
|
+
: completeUtf8Length(content, content.length);
|
|
352
|
+
return readOutput.parse({
|
|
353
|
+
process: snapshot,
|
|
354
|
+
offset: from,
|
|
355
|
+
nextOffset: from + kept,
|
|
356
|
+
text: new TextDecoder().decode(content.subarray(0, kept)),
|
|
357
|
+
complete: snapshot.state === "exited" && from + kept >= end,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
#finish(resolve) {
|
|
361
|
+
if (this.#endedAt !== null)
|
|
362
|
+
return;
|
|
363
|
+
this.#endedAt = Date.now();
|
|
364
|
+
if (this.#fd !== undefined)
|
|
365
|
+
closeSync(this.#fd);
|
|
366
|
+
this.#fd = undefined;
|
|
367
|
+
resolve(undefined);
|
|
368
|
+
}
|
|
369
|
+
/** TERM the whole group, then KILL whatever ignores it past the grace. */
|
|
370
|
+
async #terminateGroup() {
|
|
371
|
+
const groupId = this.#groupId;
|
|
372
|
+
if (groupId === undefined || !signalGroup(groupId, "SIGTERM"))
|
|
373
|
+
return false;
|
|
374
|
+
const deadline = Date.now() + terminationGraceMs;
|
|
375
|
+
while (groupAlive(groupId) && Date.now() < deadline)
|
|
376
|
+
await delay(20);
|
|
377
|
+
if (groupAlive(groupId))
|
|
378
|
+
signalGroup(groupId, "SIGKILL");
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
/** Stop this process and every member of its group; settles once it ended. */
|
|
382
|
+
async kill() {
|
|
383
|
+
if (!this.running)
|
|
384
|
+
return false;
|
|
385
|
+
const signalled = await this.#terminateGroup();
|
|
386
|
+
// A group that escaped signalling still releases our pipes and log.
|
|
387
|
+
if (this.#leaderExited || !signalled) {
|
|
388
|
+
this.#child.stdout?.destroy();
|
|
389
|
+
this.#child.stderr?.destroy();
|
|
390
|
+
}
|
|
391
|
+
await this.closed;
|
|
392
|
+
return signalled;
|
|
393
|
+
}
|
|
394
|
+
/** Synchronous last resort while the host process itself exits. */
|
|
395
|
+
killNow() {
|
|
396
|
+
if (this.running && this.#groupId !== undefined)
|
|
397
|
+
signalGroup(this.#groupId, "SIGKILL");
|
|
398
|
+
}
|
|
399
|
+
snapshot() {
|
|
400
|
+
return processSchema.parse({
|
|
401
|
+
id: this.id,
|
|
402
|
+
command: this.command,
|
|
403
|
+
cwd: this.cwd,
|
|
404
|
+
state: this.running ? "running" : "exited",
|
|
405
|
+
exitCode: this.running ? null : this.#exitCode,
|
|
406
|
+
signal: this.running ? null : this.#signal,
|
|
407
|
+
startedAt: this.startedAt,
|
|
408
|
+
endedAt: this.#endedAt,
|
|
409
|
+
logPath: this.logPath,
|
|
410
|
+
logBytes: this.#logBytes,
|
|
411
|
+
logStartOffset: Math.max(0, this.#logBytes - maximumLogBytes),
|
|
412
|
+
logTruncated: this.#logTruncated,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/** Open the background-process owner for one invocation root. */
|
|
417
|
+
export function createPrivateLocalComputerProcesses(root) {
|
|
418
|
+
const logDirectory = join(root, ".fungi", "processes");
|
|
419
|
+
const scopes = new Map();
|
|
420
|
+
let closed = false;
|
|
421
|
+
let exitHook = false;
|
|
422
|
+
const all = () => [...scopes.values()].flatMap((owned) => [...owned.values()]);
|
|
423
|
+
const killAllNow = () => {
|
|
424
|
+
for (const owned of all())
|
|
425
|
+
owned.killNow();
|
|
426
|
+
};
|
|
427
|
+
const dispose = async (owned) => {
|
|
428
|
+
const records = [...owned.values()];
|
|
429
|
+
owned.clear();
|
|
430
|
+
await Promise.all(records.map((record) => record.kill()));
|
|
431
|
+
await Promise.all(records.map((record) => rm(record.logPath, { force: true })));
|
|
432
|
+
};
|
|
433
|
+
const scope = (key) => {
|
|
434
|
+
const owned = () => {
|
|
435
|
+
let selected = scopes.get(key);
|
|
436
|
+
if (selected === undefined) {
|
|
437
|
+
selected = new Map();
|
|
438
|
+
scopes.set(key, selected);
|
|
439
|
+
}
|
|
440
|
+
return selected;
|
|
441
|
+
};
|
|
442
|
+
const find = (id) => {
|
|
443
|
+
const record = scopes.get(key)?.get(id);
|
|
444
|
+
if (record === undefined)
|
|
445
|
+
throw new PrivateLocalProcessNotFoundError(id);
|
|
446
|
+
return record;
|
|
447
|
+
};
|
|
448
|
+
return Object.freeze({
|
|
449
|
+
start: async (spec, cwd, executionPath) => {
|
|
450
|
+
if (closed)
|
|
451
|
+
throw new Error("Local Computer processes are closed");
|
|
452
|
+
const records = owned();
|
|
453
|
+
const running = [...records.values()].filter((r) => r.running).length;
|
|
454
|
+
if (running >= maximumRunningProcessesPerScope)
|
|
455
|
+
throw new PrivateLocalProcessLimitError(maximumRunningProcessesPerScope, "Session");
|
|
456
|
+
if (all().filter((r) => r.running).length >= maximumRunningProcesses)
|
|
457
|
+
throw new PrivateLocalProcessLimitError(maximumRunningProcesses, "host");
|
|
458
|
+
if (records.size >= maximumProcessRecordsPerScope) {
|
|
459
|
+
const oldest = [...records.values()].find((r) => !r.running);
|
|
460
|
+
if (oldest !== undefined) {
|
|
461
|
+
records.delete(oldest.id);
|
|
462
|
+
await rm(oldest.logPath, { force: true });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
await mkdir(logDirectory, { recursive: true, mode: 0o700 });
|
|
466
|
+
if (!exitHook) {
|
|
467
|
+
// A host that exits without close still takes its process groups.
|
|
468
|
+
process.on("exit", killAllNow);
|
|
469
|
+
exitHook = true;
|
|
470
|
+
}
|
|
471
|
+
const started = new ManagedProcess(spec, cwd, logDirectory, executionPath);
|
|
472
|
+
records.set(started.id, started);
|
|
473
|
+
return started.snapshot();
|
|
474
|
+
},
|
|
475
|
+
status: (id) => find(id).snapshot(),
|
|
476
|
+
list: () => [...(scopes.get(key)?.values() ?? [])].map((r) => r.snapshot()),
|
|
477
|
+
read: (id, offset, maxBytes) => Promise.resolve(find(id).read(offset, maxBytes)),
|
|
478
|
+
wait: async (id, timeoutMs, signal) => {
|
|
479
|
+
const record = find(id);
|
|
480
|
+
const controller = new AbortController();
|
|
481
|
+
const abandon = () => {
|
|
482
|
+
controller.abort();
|
|
483
|
+
};
|
|
484
|
+
signal.addEventListener("abort", abandon, { once: true });
|
|
485
|
+
try {
|
|
486
|
+
const timedOut = await Promise.race([
|
|
487
|
+
record.closed.then(() => false),
|
|
488
|
+
delay(timeoutMs, true, { signal: controller.signal }),
|
|
489
|
+
]);
|
|
490
|
+
return waitOutput.parse({ process: record.snapshot(), timedOut });
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
signal.removeEventListener("abort", abandon);
|
|
494
|
+
controller.abort();
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
kill: async (id) => {
|
|
498
|
+
const record = find(id);
|
|
499
|
+
const signalled = await record.kill();
|
|
500
|
+
return killOutput.parse({ process: record.snapshot(), signalled });
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
return Object.freeze({
|
|
505
|
+
scope,
|
|
506
|
+
disposeScope: async (key) => {
|
|
507
|
+
const owned = scopes.get(key);
|
|
508
|
+
scopes.delete(key);
|
|
509
|
+
if (owned !== undefined)
|
|
510
|
+
await dispose(owned);
|
|
511
|
+
},
|
|
512
|
+
close: async () => {
|
|
513
|
+
closed = true;
|
|
514
|
+
const owned = [...scopes.values()];
|
|
515
|
+
scopes.clear();
|
|
516
|
+
try {
|
|
517
|
+
await Promise.all(owned.map(dispose));
|
|
518
|
+
}
|
|
519
|
+
finally {
|
|
520
|
+
process.off("exit", killAllNow);
|
|
521
|
+
exitHook = false;
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Background operations share the Computer's one shell/exec surface; a
|
|
528
|
+
* plain (not discriminated) union, since `start`'s shell and direct branches
|
|
529
|
+
* share the `operation:"start"` literal that `discriminatedUnion` requires
|
|
530
|
+
* to be unique per branch. Both/neither of `command`/`executable` are still
|
|
531
|
+
* refused, by the strict objects themselves.
|
|
532
|
+
*/
|
|
533
|
+
export const backgroundExecInput = z.union([
|
|
534
|
+
shellStartInput,
|
|
535
|
+
directStartInput,
|
|
536
|
+
readInput.omit({ offset: true }).extend({
|
|
537
|
+
operation: z.literal("poll"),
|
|
538
|
+
// Omission reads the tail; a returned nextOffset resumes output explicitly.
|
|
539
|
+
offset: z.number().int().nonnegative().optional(),
|
|
540
|
+
}),
|
|
541
|
+
waitInput.extend({
|
|
542
|
+
operation: z.literal("wait"),
|
|
543
|
+
offset: z.number().int().nonnegative().optional(),
|
|
544
|
+
maxBytes: readInput.shape.maxBytes,
|
|
545
|
+
}),
|
|
546
|
+
idInput.extend({ operation: z.literal("kill") }),
|
|
547
|
+
z.strictObject({ operation: z.literal("list") }),
|
|
548
|
+
]);
|
|
549
|
+
const backgroundResult = readOutput.extend({
|
|
550
|
+
timedOut: z.boolean(),
|
|
551
|
+
signalled: z.boolean().optional(),
|
|
552
|
+
});
|
|
553
|
+
export const backgroundExecOutput = z.union([backgroundResult, listOutput]);
|
|
554
|
+
/** Reuse Session ownership and logs; a wait deadline never cancels the command. */
|
|
555
|
+
export async function callBackgroundExec(scope, request, resolveCwd, executionPath, signal) {
|
|
556
|
+
signal.throwIfAborted();
|
|
557
|
+
if (request.operation === "list")
|
|
558
|
+
return { processes: [...scope.list()] };
|
|
559
|
+
let id;
|
|
560
|
+
let timedOut = false;
|
|
561
|
+
let signalled;
|
|
562
|
+
switch (request.operation) {
|
|
563
|
+
case "start": {
|
|
564
|
+
const cwd = await resolveCwd(request.cwd);
|
|
565
|
+
signal.throwIfAborted();
|
|
566
|
+
id = (await scope.start(toCommandSpec(request), cwd, executionPath)).id;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case "wait":
|
|
570
|
+
id = request.id;
|
|
571
|
+
timedOut = (await scope.wait(id, request.timeoutMs, signal)).timedOut;
|
|
572
|
+
break;
|
|
573
|
+
case "kill":
|
|
574
|
+
id = request.id;
|
|
575
|
+
signalled = (await scope.kill(id)).signalled;
|
|
576
|
+
break;
|
|
577
|
+
case "poll":
|
|
578
|
+
id = request.id;
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
const maxBytes = "maxBytes" in request ? request.maxBytes : defaultReadBytes;
|
|
582
|
+
const offset = "offset" in request ? request.offset : undefined;
|
|
583
|
+
const from = offset ?? Math.max(0, scope.status(id).logBytes - maxBytes);
|
|
584
|
+
const output = await scope.read(id, from, maxBytes);
|
|
585
|
+
const result = { ...output, timedOut };
|
|
586
|
+
if (signalled !== undefined)
|
|
587
|
+
result.signalled = signalled;
|
|
588
|
+
return backgroundResult.parse(result);
|
|
589
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Models } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelPreference } from "@fungi.computer/shiitake/model-preference";
|
|
3
|
+
/** One model offered by a signed-in or configured provider, as a provider/model address. */
|
|
4
|
+
export type OfferedLocalModel = Readonly<{
|
|
5
|
+
address: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}>;
|
|
8
|
+
/** The provider registry and its auth owner determine the available fallback. */
|
|
9
|
+
export declare function defaultLocalModel(models: Models, signal: AbortSignal): Promise<ModelPreference | null>;
|
|
10
|
+
/** List models from signed-in or configured providers only, in registry order. */
|
|
11
|
+
export declare function listOfferedLocalModels(models: Models, signal: AbortSignal): Promise<readonly OfferedLocalModel[]>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { logEvent } from "./log-event.js";
|
|
2
|
+
/** Return a provider's catalog only when its auth owner admits it. */
|
|
3
|
+
async function offeredCatalog(models, providerId, signal) {
|
|
4
|
+
signal.throwIfAborted();
|
|
5
|
+
try {
|
|
6
|
+
if ((await models.checkAuth(providerId, { signal })) === undefined)
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
catch (cause) {
|
|
10
|
+
// A provider whose login cannot be resolved offers no models here.
|
|
11
|
+
// Explicit selections report their own safe, provider-specific refusal.
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
logEvent("model-default.check-auth", cause);
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
if (models.getModels(providerId).length === 0) {
|
|
17
|
+
const refreshed = await models.refresh({
|
|
18
|
+
providers: [providerId],
|
|
19
|
+
signal,
|
|
20
|
+
});
|
|
21
|
+
signal.throwIfAborted();
|
|
22
|
+
if (refreshed.aborted || refreshed.errors.has(providerId))
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return models.getModels(providerId);
|
|
26
|
+
}
|
|
27
|
+
/** The provider registry and its auth owner determine the available fallback. */
|
|
28
|
+
export async function defaultLocalModel(models, signal) {
|
|
29
|
+
for (const provider of models.getProviders()) {
|
|
30
|
+
const catalog = await offeredCatalog(models, provider.id, signal);
|
|
31
|
+
const model = catalog.find((entry) => "default" in entry && entry.default === true) ??
|
|
32
|
+
catalog[0];
|
|
33
|
+
if (model !== undefined)
|
|
34
|
+
return { providerId: provider.id, modelId: model.id };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** List models from signed-in or configured providers only, in registry order. */
|
|
39
|
+
export async function listOfferedLocalModels(models, signal) {
|
|
40
|
+
const offered = [];
|
|
41
|
+
for (const provider of models.getProviders())
|
|
42
|
+
for (const model of await offeredCatalog(models, provider.id, signal))
|
|
43
|
+
offered.push({ address: `${provider.id}/${model.id}`, name: model.name });
|
|
44
|
+
return offered;
|
|
45
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BirdDogMail } from "@fungi.computer/bird-dog";
|
|
2
|
+
import type { Accepted, CommandSettlement, VerifiedAgentReply } from "@fungi.computer/shiitake";
|
|
3
|
+
import type { AgentControlDirectory, AgentDelegationInput, AgentDelegationInvocation } from "@fungi.computer/shiitake/agent-control";
|
|
4
|
+
import { createFleetDelegation } from "@fungi.computer/shiitake/fleet";
|
|
5
|
+
/**
|
|
6
|
+
* The Local service's project mail routing, shared by every Agent it owns.
|
|
7
|
+
* `deliver` admits frozen mail at its addressed Agent, opening that Agent's
|
|
8
|
+
* owner when it is idle; `readSettlement` reads one exact command there.
|
|
9
|
+
*/
|
|
10
|
+
export type PrivateLocalMailRouting = Readonly<{
|
|
11
|
+
deliver: (mail: BirdDogMail) => Promise<Accepted>;
|
|
12
|
+
readSettlement: (agentId: string, sessionId: string, commandId: string) => Promise<CommandSettlement | undefined>;
|
|
13
|
+
}>;
|
|
14
|
+
/**
|
|
15
|
+
* One Local Agent's side of delegation, over the same fleet contract as the
|
|
16
|
+
* Hub: a `followUp` send freezes BirdDog mail in this Agent's store, and an
|
|
17
|
+
* arriving reply is admitted only after `fleet.authenticateReply` checks it
|
|
18
|
+
* against that frozen mail. The replying child authors the reply line.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createPrivateLocalDelegation(input: Readonly<{
|
|
21
|
+
agentId: string;
|
|
22
|
+
storage: Parameters<typeof createFleetDelegation>[0]["storage"];
|
|
23
|
+
directory: AgentControlDirectory;
|
|
24
|
+
routing: PrivateLocalMailRouting;
|
|
25
|
+
accept: (mail: BirdDogMail, meta?: BirdDogMail["meta"], reply?: VerifiedAgentReply) => Promise<Accepted>;
|
|
26
|
+
}>): {
|
|
27
|
+
send: (task: AgentDelegationInput, sourceSessionId: string, signal: AbortSignal, invocation?: AgentDelegationInvocation) => Promise<Accepted>;
|
|
28
|
+
receive: (mail: BirdDogMail) => Promise<Accepted>;
|
|
29
|
+
};
|