@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,988 @@
|
|
|
1
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
|
2
|
+
import { isContextOverflow } from "@earendil-works/pi-ai/utils/overflow";
|
|
3
|
+
import { isRetryableAssistantError } from "@earendil-works/pi-ai/utils/retry";
|
|
4
|
+
import { logEvent } from "./log-event.js";
|
|
5
|
+
import { localExtensions } from "./local-extensions.js";
|
|
6
|
+
import { lstat, mkdir, realpath, stat } from "node:fs/promises";
|
|
7
|
+
import { lstatSync, mkdirSync, realpathSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { cleanupSessionResources, clampThinkingLevel, getSupportedThinkingLevels, } from "@earendil-works/pi-ai";
|
|
12
|
+
import { parseModelPreference, parseModelPreferenceJson, resolveModelPreference, } from "@fungi.computer/shiitake/model-preference";
|
|
13
|
+
import { createAgentControlSource, } from "@fungi.computer/shiitake/agent-control";
|
|
14
|
+
import { defineIdFamily } from "@fungi.computer/cairn";
|
|
15
|
+
import { botanicalExtensionsWithSkills } from "@fungi.computer/shiitake-extensions/roster";
|
|
16
|
+
import { homeExtension, homeGrant, } from "@fungi.computer/shiitake-extensions/home/host";
|
|
17
|
+
import { Knapsack } from "@fungi.computer/knapsack";
|
|
18
|
+
import { makeHost, Shiitake, ShiitakeError, } from "@fungi.computer/shiitake";
|
|
19
|
+
import { createPrivateModels } from "./local-models.js";
|
|
20
|
+
import { defaultLocalModel, listOfferedLocalModels, } from "./private-local-default-model.js";
|
|
21
|
+
import { createLocalModelSettings } from "./private-local-model-settings.js";
|
|
22
|
+
import { assertFungiModelsEnabled } from "./private-fungi-model-api.js";
|
|
23
|
+
import { privateLocalEffectiveModelSchema, } from "./private-local-effective-model.js";
|
|
24
|
+
import { createPrivateInvocationComputer } from "./private-invocation-computer.js";
|
|
25
|
+
import { createPrivateLocalComputerProcesses, } from "./private-local-computer-processes.js";
|
|
26
|
+
import { capturePrivateLocalExecPath, parsePrivateLocalExecPath, } from "./private-local-exec-path.js";
|
|
27
|
+
import { ensurePrivateLocalGitExclude } from "./private-local-git-exclude.js";
|
|
28
|
+
import { acquireNodeStoreLease, nodeRuntimeHost, } from "@fungi.computer/shiitake/node";
|
|
29
|
+
import { createPrivateInvocationFilesPort } from "./private-invocation-files.js";
|
|
30
|
+
import { createLocalSkillSource } from "./local-skill-source.js";
|
|
31
|
+
import { createPrivateWorkerdSandbox } from "./private-workerd-sandbox.js";
|
|
32
|
+
import { createPrivateFungiMcpConnection, } from "./private-fungi-mcp-connection.js";
|
|
33
|
+
import { createPrivateSqliteOwner } from "./private-sqlite-owner.js";
|
|
34
|
+
import { createPrivateLocalMailRoom } from "./private-local-mail.js";
|
|
35
|
+
import { createPrivateLocalDelegation, } from "./private-local-delegation.js";
|
|
36
|
+
import { createPrivateLocalSubagents } from "./private-local-subagents.js";
|
|
37
|
+
import { subagentsExtension, subagentsGrant, } from "@fungi.computer/shiitake-extensions/subagents/host";
|
|
38
|
+
const initialSessionId = "main";
|
|
39
|
+
const maximumInstructionReadBytes = 128 * 1_024;
|
|
40
|
+
const maximumInstructionSnapshotBytes = 32_000;
|
|
41
|
+
const instructionRevisionVersion = "local-host-instructions-v2";
|
|
42
|
+
const authoredInstructions = "You are the local coding agent. Explain durable changes clearly. Discover available operations through forage.search and inspect their input with forage.describe; call only the exact paths they return. Extension prompt sections describe the Session's tools and controls. After a run fails, each new user message is a new instruction. Retry or continue the previous task only when the user explicitly asks; if the new message is ambiguous, ask what they want before acting.";
|
|
43
|
+
const utf8 = new TextEncoder();
|
|
44
|
+
const localHostIds = defineIdFamily({
|
|
45
|
+
family: "local-host",
|
|
46
|
+
version: 1,
|
|
47
|
+
kinds: {
|
|
48
|
+
store: { prefix: "local_store" },
|
|
49
|
+
"session-create-request": { prefix: "local_session_create_request" },
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
function isErrnoException(error) {
|
|
53
|
+
return "code" in error;
|
|
54
|
+
}
|
|
55
|
+
function sidecarPathFor(databasePath) {
|
|
56
|
+
return `${databasePath}.owner.sqlite`;
|
|
57
|
+
}
|
|
58
|
+
function ensureLocalAgentId(database, pathAgentId) {
|
|
59
|
+
database.exec("BEGIN IMMEDIATE");
|
|
60
|
+
try {
|
|
61
|
+
database.exec(`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS private_local_identity (
|
|
63
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
64
|
+
agent_id TEXT NOT NULL UNIQUE
|
|
65
|
+
)
|
|
66
|
+
`);
|
|
67
|
+
const row = database
|
|
68
|
+
.prepare("SELECT agent_id FROM private_local_identity WHERE singleton = 1")
|
|
69
|
+
.get();
|
|
70
|
+
if (row !== undefined) {
|
|
71
|
+
const parsed = localHostIds.store.schema.safeParse(row.agent_id);
|
|
72
|
+
if (!parsed.success ||
|
|
73
|
+
(pathAgentId !== undefined && parsed.data !== pathAgentId))
|
|
74
|
+
throw new Error("Local Agent identity does not match its store");
|
|
75
|
+
database.exec("COMMIT");
|
|
76
|
+
return parsed.data;
|
|
77
|
+
}
|
|
78
|
+
const agentId = pathAgentId ?? localHostIds.store.generate();
|
|
79
|
+
database
|
|
80
|
+
.prepare("INSERT INTO private_local_identity (singleton, agent_id) VALUES (1, ?)")
|
|
81
|
+
.run(agentId);
|
|
82
|
+
database.exec("COMMIT");
|
|
83
|
+
return agentId;
|
|
84
|
+
}
|
|
85
|
+
catch (cause) {
|
|
86
|
+
if (database.isTransaction) {
|
|
87
|
+
try {
|
|
88
|
+
database.exec("ROLLBACK");
|
|
89
|
+
}
|
|
90
|
+
catch (rollbackCause) {
|
|
91
|
+
throw new AggregateError([cause, rollbackCause], "Local transaction and rollback failed", { cause: rollbackCause });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw cause;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Local-owned id for a new Session creation request. */
|
|
98
|
+
export function createPrivateLocalSessionRequestId() {
|
|
99
|
+
return localHostIds["session-create-request"].generate();
|
|
100
|
+
}
|
|
101
|
+
const localCreateReceiptFile = "local-agent-create-receipts.sqlite";
|
|
102
|
+
const localCreateReceiptTable = "local_agent_create_receipts";
|
|
103
|
+
const maximumReceiptIdAttempts = 3;
|
|
104
|
+
const receiptBusyTimeoutMs = 5_000;
|
|
105
|
+
async function ensurePrivateLocalDirectory(path) {
|
|
106
|
+
try {
|
|
107
|
+
await mkdir(path);
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
if (!(cause instanceof Error) ||
|
|
111
|
+
!isErrnoException(cause) ||
|
|
112
|
+
cause.code !== "EEXIST")
|
|
113
|
+
throw cause;
|
|
114
|
+
}
|
|
115
|
+
const metadata = await lstat(path);
|
|
116
|
+
const canonical = await realpath(path);
|
|
117
|
+
if (!metadata.isDirectory() ||
|
|
118
|
+
metadata.isSymbolicLink() ||
|
|
119
|
+
canonical !== path)
|
|
120
|
+
throw new Error("Local Agent create directory is not project owned");
|
|
121
|
+
}
|
|
122
|
+
async function assertPrivateLocalReceiptFile(path) {
|
|
123
|
+
// A concurrent create's commit deletes the rollback journal at any point
|
|
124
|
+
// between these reads; a file that is gone has nothing left to check.
|
|
125
|
+
try {
|
|
126
|
+
const metadata = await lstat(path);
|
|
127
|
+
if (!metadata.isFile() ||
|
|
128
|
+
metadata.isSymbolicLink() ||
|
|
129
|
+
metadata.nlink !== 1 ||
|
|
130
|
+
(await realpath(path)) !== path)
|
|
131
|
+
throw new Error("Local Agent create receipt is not a unique project file");
|
|
132
|
+
}
|
|
133
|
+
catch (cause) {
|
|
134
|
+
if (cause instanceof Error &&
|
|
135
|
+
isErrnoException(cause) &&
|
|
136
|
+
cause.code === "ENOENT")
|
|
137
|
+
return;
|
|
138
|
+
throw cause;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Resolve an Agent create request through its durable Local receipt. */
|
|
142
|
+
export async function resolvePrivateLocalCreateStore(root, requestId) {
|
|
143
|
+
if (requestId.length === 0 || requestId.length > 256)
|
|
144
|
+
throw new Error("Local Agent create request id is invalid");
|
|
145
|
+
await ensurePrivateLocalGitExclude(root);
|
|
146
|
+
const fungiDirectory = join(root, ".fungi");
|
|
147
|
+
const agentsDirectory = join(fungiDirectory, "agents");
|
|
148
|
+
await ensurePrivateLocalDirectory(fungiDirectory);
|
|
149
|
+
await ensurePrivateLocalDirectory(agentsDirectory);
|
|
150
|
+
const receiptPath = join(agentsDirectory, localCreateReceiptFile);
|
|
151
|
+
await assertPrivateLocalReceiptFile(receiptPath);
|
|
152
|
+
await assertPrivateLocalReceiptFile(`${receiptPath}-journal`);
|
|
153
|
+
const database = new DatabaseSync(receiptPath, {
|
|
154
|
+
timeout: receiptBusyTimeoutMs,
|
|
155
|
+
});
|
|
156
|
+
try {
|
|
157
|
+
database.exec(`PRAGMA busy_timeout = ${String(receiptBusyTimeoutMs)}`);
|
|
158
|
+
database.exec("BEGIN IMMEDIATE");
|
|
159
|
+
database.exec(`
|
|
160
|
+
CREATE TABLE IF NOT EXISTS ${localCreateReceiptTable} (
|
|
161
|
+
request_id TEXT PRIMARY KEY NOT NULL,
|
|
162
|
+
agent_id TEXT NOT NULL UNIQUE
|
|
163
|
+
) WITHOUT ROWID
|
|
164
|
+
`);
|
|
165
|
+
const existing = database
|
|
166
|
+
.prepare(`SELECT agent_id FROM ${localCreateReceiptTable} WHERE request_id = ?`)
|
|
167
|
+
.get(requestId);
|
|
168
|
+
if (existing !== undefined) {
|
|
169
|
+
const parsed = localHostIds.store.schema.safeParse(existing.agent_id);
|
|
170
|
+
if (!parsed.success)
|
|
171
|
+
throw new Error("Local Agent create receipt is invalid");
|
|
172
|
+
const agentId = parsed.data;
|
|
173
|
+
database.exec("COMMIT");
|
|
174
|
+
await ensurePrivateLocalDirectory(join(agentsDirectory, agentId));
|
|
175
|
+
return {
|
|
176
|
+
agentId,
|
|
177
|
+
databasePath: join(agentsDirectory, agentId, "agent.sqlite"),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
let agentId;
|
|
181
|
+
const insert = database.prepare(`INSERT OR IGNORE INTO ${localCreateReceiptTable} (request_id, agent_id) VALUES (?, ?)`);
|
|
182
|
+
for (let attempt = 0; attempt < maximumReceiptIdAttempts; attempt += 1) {
|
|
183
|
+
const candidate = localHostIds.store.generate();
|
|
184
|
+
const candidateDirectory = join(agentsDirectory, candidate);
|
|
185
|
+
try {
|
|
186
|
+
// Keep the SQLite write transaction synchronous. Awaiting filesystem
|
|
187
|
+
// work here lets a concurrent same-process retry block the event loop
|
|
188
|
+
// in BEGIN IMMEDIATE, preventing this mkdir continuation from running.
|
|
189
|
+
mkdirSync(candidateDirectory);
|
|
190
|
+
}
|
|
191
|
+
catch (cause) {
|
|
192
|
+
if (cause instanceof Error &&
|
|
193
|
+
isErrnoException(cause) &&
|
|
194
|
+
cause.code === "EEXIST")
|
|
195
|
+
continue;
|
|
196
|
+
throw cause;
|
|
197
|
+
}
|
|
198
|
+
const metadata = lstatSync(candidateDirectory);
|
|
199
|
+
if (!metadata.isDirectory() ||
|
|
200
|
+
metadata.isSymbolicLink() ||
|
|
201
|
+
realpathSync(candidateDirectory) !== candidateDirectory)
|
|
202
|
+
throw new Error("Local Agent create directory is not project owned");
|
|
203
|
+
if (insert.run(requestId, candidate).changes > 0) {
|
|
204
|
+
agentId = candidate;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (agentId === undefined)
|
|
209
|
+
throw new Error("Could not allocate a unique Local Agent identity");
|
|
210
|
+
database.exec("COMMIT");
|
|
211
|
+
return {
|
|
212
|
+
agentId,
|
|
213
|
+
databasePath: join(agentsDirectory, agentId, "agent.sqlite"),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (cause) {
|
|
217
|
+
if (database.isTransaction) {
|
|
218
|
+
try {
|
|
219
|
+
database.exec("ROLLBACK");
|
|
220
|
+
}
|
|
221
|
+
catch (rollbackCause) {
|
|
222
|
+
throw new AggregateError([cause, rollbackCause], "Local transaction and rollback failed", { cause: rollbackCause });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
throw cause;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
database.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function localDatabasePaths(root, requested, createRequestId) {
|
|
232
|
+
if (requested !== undefined && createRequestId !== undefined)
|
|
233
|
+
throw new Error("Local Agent database and create request are mutually exclusive");
|
|
234
|
+
if (requested !== undefined) {
|
|
235
|
+
const existing = await realpath(resolve(root, requested));
|
|
236
|
+
const metadata = await stat(existing);
|
|
237
|
+
if (!metadata.isFile())
|
|
238
|
+
throw new Error(`Local Agent database is not a file: ${requested}`);
|
|
239
|
+
const parent = await realpath(dirname(existing));
|
|
240
|
+
const databasePath = join(parent, basename(existing));
|
|
241
|
+
const parsedAgentId = localHostIds.store.parse(basename(parent));
|
|
242
|
+
const agentId = parsedAgentId.ok ? parsedAgentId.value : undefined;
|
|
243
|
+
return { agentId, databasePath, sidecarPath: sidecarPathFor(databasePath) };
|
|
244
|
+
}
|
|
245
|
+
const createStore = createRequestId === undefined
|
|
246
|
+
? undefined
|
|
247
|
+
: await resolvePrivateLocalCreateStore(root, createRequestId);
|
|
248
|
+
const createDatabasePath = createStore?.databasePath;
|
|
249
|
+
const generatedAgentId = createStore?.agentId ?? localHostIds.store.generate();
|
|
250
|
+
const selectedParent = createDatabasePath === undefined
|
|
251
|
+
? join(root, ".fungi", "agents", generatedAgentId)
|
|
252
|
+
: dirname(createDatabasePath);
|
|
253
|
+
let parent;
|
|
254
|
+
if (createRequestId === undefined) {
|
|
255
|
+
await mkdir(selectedParent, { recursive: true });
|
|
256
|
+
parent = await realpath(selectedParent);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
for (const path of [
|
|
260
|
+
join(root, ".fungi"),
|
|
261
|
+
join(root, ".fungi", "agents"),
|
|
262
|
+
selectedParent,
|
|
263
|
+
]) {
|
|
264
|
+
try {
|
|
265
|
+
await mkdir(path);
|
|
266
|
+
}
|
|
267
|
+
catch (cause) {
|
|
268
|
+
if (!(cause instanceof Error) ||
|
|
269
|
+
!isErrnoException(cause) ||
|
|
270
|
+
cause.code !== "EEXIST")
|
|
271
|
+
throw cause;
|
|
272
|
+
}
|
|
273
|
+
const metadata = await lstat(path);
|
|
274
|
+
const canonical = await realpath(path);
|
|
275
|
+
if (!metadata.isDirectory() ||
|
|
276
|
+
metadata.isSymbolicLink() ||
|
|
277
|
+
canonical !== path)
|
|
278
|
+
throw new Error("Local Agent create directory is not project owned");
|
|
279
|
+
}
|
|
280
|
+
parent = await realpath(selectedParent);
|
|
281
|
+
const withinProject = relative(root, parent);
|
|
282
|
+
if (isAbsolute(withinProject) ||
|
|
283
|
+
withinProject === ".." ||
|
|
284
|
+
withinProject.startsWith("../"))
|
|
285
|
+
throw new Error("Local Agent create directory is outside this project");
|
|
286
|
+
}
|
|
287
|
+
const databasePath = createDatabasePath ?? join(parent, "agent.sqlite");
|
|
288
|
+
if (createRequestId !== undefined) {
|
|
289
|
+
for (const path of [databasePath, sidecarPathFor(databasePath)]) {
|
|
290
|
+
let metadata;
|
|
291
|
+
try {
|
|
292
|
+
metadata = await lstat(path);
|
|
293
|
+
}
|
|
294
|
+
catch (cause) {
|
|
295
|
+
if (!(cause instanceof Error) ||
|
|
296
|
+
!isErrnoException(cause) ||
|
|
297
|
+
cause.code !== "ENOENT")
|
|
298
|
+
throw cause;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (!metadata.isFile() ||
|
|
302
|
+
metadata.isSymbolicLink() ||
|
|
303
|
+
metadata.nlink !== 1)
|
|
304
|
+
throw new Error("Local Agent create store is not a unique project file");
|
|
305
|
+
if ((await realpath(path)) !== path)
|
|
306
|
+
throw new Error("Local Agent create store is not a unique project file");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
agentId: generatedAgentId,
|
|
311
|
+
databasePath,
|
|
312
|
+
sidecarPath: sidecarPathFor(databasePath),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function frame(parts) {
|
|
316
|
+
const total = parts.reduce((size, part) => size + 4 + part.byteLength, 0);
|
|
317
|
+
const framed = new Uint8Array(total);
|
|
318
|
+
const view = new DataView(framed.buffer);
|
|
319
|
+
let offset = 0;
|
|
320
|
+
for (const part of parts) {
|
|
321
|
+
view.setUint32(offset, part.byteLength);
|
|
322
|
+
offset += 4;
|
|
323
|
+
framed.set(part, offset);
|
|
324
|
+
offset += part.byteLength;
|
|
325
|
+
}
|
|
326
|
+
return framed;
|
|
327
|
+
}
|
|
328
|
+
async function instructionRevision(authored, agents) {
|
|
329
|
+
const digest = await crypto.subtle.digest("SHA-256", frame([
|
|
330
|
+
utf8.encode(instructionRevisionVersion),
|
|
331
|
+
utf8.encode(authored),
|
|
332
|
+
Uint8Array.of(agents === undefined ? 0 : 1),
|
|
333
|
+
utf8.encode(agents ?? ""),
|
|
334
|
+
]));
|
|
335
|
+
return `local-instructions-${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
336
|
+
}
|
|
337
|
+
/** Build the fixed source read by Shiitake once for each local run. */
|
|
338
|
+
export function createPrivateLocalInstructionSource(port) {
|
|
339
|
+
return Object.freeze({
|
|
340
|
+
load: async ({ sessionId, signal }) => {
|
|
341
|
+
signal.throwIfAborted();
|
|
342
|
+
let agents;
|
|
343
|
+
try {
|
|
344
|
+
const page = await port.read({ path: "AGENTS.md", offset: 0, limit: maximumInstructionReadBytes }, {
|
|
345
|
+
executionId: `local-instructions/${sessionId}`,
|
|
346
|
+
deadlineAt: null,
|
|
347
|
+
signal,
|
|
348
|
+
});
|
|
349
|
+
if (page.truncated)
|
|
350
|
+
throw new Error("Root AGENTS.md exceeds the 128 KiB instruction bound");
|
|
351
|
+
agents = page.content;
|
|
352
|
+
}
|
|
353
|
+
catch (cause) {
|
|
354
|
+
if (cause instanceof Error &&
|
|
355
|
+
isErrnoException(cause) &&
|
|
356
|
+
cause.code === "ENOENT") {
|
|
357
|
+
agents = undefined;
|
|
358
|
+
}
|
|
359
|
+
else
|
|
360
|
+
throw cause;
|
|
361
|
+
}
|
|
362
|
+
signal.throwIfAborted();
|
|
363
|
+
const text = agents === undefined
|
|
364
|
+
? authoredInstructions
|
|
365
|
+
: `${authoredInstructions}\n\n${agents}`;
|
|
366
|
+
if (utf8.encode(text).byteLength > maximumInstructionSnapshotBytes)
|
|
367
|
+
throw new Error(`Local instructions exceed Shiitake's ${String(maximumInstructionSnapshotBytes)}-byte snapshot bound`);
|
|
368
|
+
return Object.freeze({
|
|
369
|
+
revision: await instructionRevision(authoredInstructions, agents),
|
|
370
|
+
text,
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/** Validate the selected registered model before resolving credentials. */
|
|
376
|
+
function validateLocalReasoning(model, requested) {
|
|
377
|
+
if (requested === undefined)
|
|
378
|
+
return;
|
|
379
|
+
const supported = getSupportedThinkingLevels(model).filter((level) => level !== "off");
|
|
380
|
+
if (!supported.includes(requested))
|
|
381
|
+
throw new Error("Reasoning level " +
|
|
382
|
+
requested +
|
|
383
|
+
" is not supported by " +
|
|
384
|
+
model.provider +
|
|
385
|
+
"/" +
|
|
386
|
+
model.id +
|
|
387
|
+
"; supported levels: " +
|
|
388
|
+
(supported.join(", ") || "none"));
|
|
389
|
+
}
|
|
390
|
+
/** Local's default is medium, clamped through Pi's model capabilities. */
|
|
391
|
+
function defaultLocalReasoning(model) {
|
|
392
|
+
const level = clampThinkingLevel(model, "medium");
|
|
393
|
+
return level === "off" ? undefined : level;
|
|
394
|
+
}
|
|
395
|
+
export function createPrivateLocalModelSource(models, providerId, modelId, providerCacheKey, selectedOwnerReasoning, defaults) {
|
|
396
|
+
const model = modelId === undefined || providerId === undefined
|
|
397
|
+
? undefined
|
|
398
|
+
: models.getModel(providerId, modelId);
|
|
399
|
+
if (providerId !== undefined && providerId !== "fungi" && model === undefined)
|
|
400
|
+
throw new ShiitakeError("model", "rejected", `Local model is not registered: ${providerId}/${modelId ?? "select with --model"}`);
|
|
401
|
+
if (model !== undefined)
|
|
402
|
+
validateLocalReasoning(model, selectedOwnerReasoning);
|
|
403
|
+
const ownerReasoning = selectedOwnerReasoning ??
|
|
404
|
+
(model === undefined ? undefined : defaultLocalReasoning(model));
|
|
405
|
+
const select = async (preference, signal) => {
|
|
406
|
+
const agent = defaults?.agent?.() ??
|
|
407
|
+
(providerId === undefined
|
|
408
|
+
? null
|
|
409
|
+
: {
|
|
410
|
+
providerId,
|
|
411
|
+
modelId: modelId ?? "@fungi/luna",
|
|
412
|
+
});
|
|
413
|
+
const explicit = resolveModelPreference({
|
|
414
|
+
session: preference,
|
|
415
|
+
agent,
|
|
416
|
+
manual: preference === null && agent === null ? await defaults?.manual() : null,
|
|
417
|
+
});
|
|
418
|
+
const choice = resolveModelPreference({
|
|
419
|
+
session: explicit,
|
|
420
|
+
available: explicit === null ? await defaultLocalModel(models, signal) : null,
|
|
421
|
+
});
|
|
422
|
+
if (choice === null)
|
|
423
|
+
throw new ShiitakeError("model", "rejected", "No signed-in model provider is available. Run /login, then choose a model with /model.");
|
|
424
|
+
const selectedProvider = choice.providerId;
|
|
425
|
+
if (selectedProvider === "fungi") {
|
|
426
|
+
try {
|
|
427
|
+
assertFungiModelsEnabled();
|
|
428
|
+
}
|
|
429
|
+
catch (cause) {
|
|
430
|
+
throw new ShiitakeError("model", "rejected", "Fungi models are paused. Try again later.", undefined, cause);
|
|
431
|
+
}
|
|
432
|
+
await assertAuthenticated("fungi", signal);
|
|
433
|
+
// Catalog facts choose a model; every stream rechecks live Hub authority.
|
|
434
|
+
// A failed catalog refresh must not hide a dispatch refusal from the transcript.
|
|
435
|
+
if (models.getModels("fungi").length === 0) {
|
|
436
|
+
const result = await models.refresh({ providers: ["fungi"], signal });
|
|
437
|
+
if (result.aborted)
|
|
438
|
+
throw new Error("Fungi model catalog refresh was cancelled");
|
|
439
|
+
const failure = result.errors.get("fungi");
|
|
440
|
+
if (failure !== undefined)
|
|
441
|
+
throw new ShiitakeError("model", "rejected", "Fungi models are unavailable. Sign in with /login or choose another provider with /model.", undefined, failure);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const selectedId = choice.modelId;
|
|
445
|
+
const selected = models.getModel(selectedProvider, selectedId);
|
|
446
|
+
if (selected === undefined)
|
|
447
|
+
throw new ShiitakeError("model", "rejected", `Local model is not registered: ${selectedProvider}/${selectedId}`);
|
|
448
|
+
const reasoning = choice.reasoning ??
|
|
449
|
+
(ownerReasoning !== undefined &&
|
|
450
|
+
getSupportedThinkingLevels(selected).includes(ownerReasoning)
|
|
451
|
+
? ownerReasoning
|
|
452
|
+
: defaultLocalReasoning(selected));
|
|
453
|
+
validateLocalReasoning(selected, reasoning);
|
|
454
|
+
return { selected, reasoning };
|
|
455
|
+
};
|
|
456
|
+
const assertAuthenticated = async (provider, signal) => {
|
|
457
|
+
const message = `${provider} is not signed in. Run /login or choose another model with /model.`;
|
|
458
|
+
try {
|
|
459
|
+
if ((await models.checkAuth(provider, { signal })) === undefined)
|
|
460
|
+
throw new ShiitakeError("model", "rejected", message);
|
|
461
|
+
signal.throwIfAborted();
|
|
462
|
+
}
|
|
463
|
+
catch (cause) {
|
|
464
|
+
signal.throwIfAborted();
|
|
465
|
+
throw new ShiitakeError("model", "rejected", message, undefined, cause);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
return Object.freeze({
|
|
469
|
+
assertReady: async (preference, signal) => {
|
|
470
|
+
const { selected } = await select(preference, signal);
|
|
471
|
+
await assertAuthenticated(selected.provider, signal);
|
|
472
|
+
},
|
|
473
|
+
effective: async (preference, signal) => {
|
|
474
|
+
signal.throwIfAborted();
|
|
475
|
+
let selection;
|
|
476
|
+
try {
|
|
477
|
+
selection = await select(preference, signal);
|
|
478
|
+
}
|
|
479
|
+
catch (cause) {
|
|
480
|
+
if (signal.aborted ||
|
|
481
|
+
(preference?.providerId ?? providerId) !== "fungi")
|
|
482
|
+
throw cause;
|
|
483
|
+
logEvent("fungi-model.status", cause);
|
|
484
|
+
const source = cause instanceof ShiitakeError
|
|
485
|
+
? cause.message
|
|
486
|
+
: "Fungi is unreachable or unavailable. Retry or sign in again.";
|
|
487
|
+
return privateLocalEffectiveModelSchema.parse({
|
|
488
|
+
agentName: "Shiitake",
|
|
489
|
+
providerId: "fungi",
|
|
490
|
+
providerName: "Fungi",
|
|
491
|
+
modelId: preference?.modelId ?? modelId ?? "@fungi/luna",
|
|
492
|
+
modelName: "Fungi",
|
|
493
|
+
credential: { kind: "missing", source },
|
|
494
|
+
label: `Shiitake · Fungi · ${source}`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
const { selected, reasoning } = selection;
|
|
498
|
+
const provider = models.getProvider(selected.provider);
|
|
499
|
+
if (provider === undefined)
|
|
500
|
+
throw new Error("Local model provider is not registered");
|
|
501
|
+
const auth = await models.checkAuth(provider.id, { signal });
|
|
502
|
+
signal.throwIfAborted();
|
|
503
|
+
const source = auth === undefined
|
|
504
|
+
? "Sign in required"
|
|
505
|
+
: auth.source === undefined || auth.source === "OAuth"
|
|
506
|
+
? auth.type === "oauth"
|
|
507
|
+
? `${provider.name} login`
|
|
508
|
+
: "API key"
|
|
509
|
+
: auth.source;
|
|
510
|
+
return privateLocalEffectiveModelSchema.parse({
|
|
511
|
+
agentName: "Shiitake",
|
|
512
|
+
providerId: provider.id,
|
|
513
|
+
providerName: provider.name,
|
|
514
|
+
modelId: selected.id,
|
|
515
|
+
modelName: selected.name,
|
|
516
|
+
credential: { kind: auth?.type ?? "missing", source },
|
|
517
|
+
label: `Shiitake · ${selected.name} (${reasoning ?? "no reasoning"}) · ${source}`,
|
|
518
|
+
});
|
|
519
|
+
},
|
|
520
|
+
// The window comes from the same registry entry a run would acquire.
|
|
521
|
+
describe: async ({ signal, preference }) => {
|
|
522
|
+
signal.throwIfAborted();
|
|
523
|
+
const { selected } = await select(preference, signal);
|
|
524
|
+
return {
|
|
525
|
+
contextWindow: selected.contextWindow > 0 ? selected.contextWindow : null,
|
|
526
|
+
};
|
|
527
|
+
},
|
|
528
|
+
acquire: async ({ signal, preference }) => {
|
|
529
|
+
signal.throwIfAborted();
|
|
530
|
+
const { selected, reasoning: selectedReasoning } = await select(preference, signal);
|
|
531
|
+
await assertAuthenticated(selected.provider, signal);
|
|
532
|
+
return Promise.resolve(Object.freeze({
|
|
533
|
+
model: selected,
|
|
534
|
+
stream: (selected, context, options) => {
|
|
535
|
+
const streamOptions = { ...options };
|
|
536
|
+
const reasoning = options?.reasoning ?? selectedReasoning;
|
|
537
|
+
if (reasoning !== undefined)
|
|
538
|
+
streamOptions.reasoning = reasoning;
|
|
539
|
+
const events = createAssistantMessageEventStream();
|
|
540
|
+
const failure = (usage, aborted, upstream) => {
|
|
541
|
+
let message = `${selected.provider} rejected the model request. Check /login or choose another model with /model.`;
|
|
542
|
+
if (upstream !== undefined) {
|
|
543
|
+
if (isContextOverflow(upstream))
|
|
544
|
+
message = `${selected.provider} request exceeds the context window. Compact the Session or choose another model.`;
|
|
545
|
+
else if (isRetryableAssistantError(upstream))
|
|
546
|
+
message = `${selected.provider} service unavailable. Try again shortly.`;
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
role: "assistant",
|
|
550
|
+
content: [],
|
|
551
|
+
api: selected.api,
|
|
552
|
+
provider: selected.provider,
|
|
553
|
+
model: selected.id,
|
|
554
|
+
usage,
|
|
555
|
+
stopReason: aborted ? "aborted" : "error",
|
|
556
|
+
timestamp: Date.now(),
|
|
557
|
+
errorMessage: aborted ? "Run cancelled" : message,
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
void (async () => {
|
|
561
|
+
try {
|
|
562
|
+
const upstream = models.streamSimple(selected, context, {
|
|
563
|
+
...streamOptions,
|
|
564
|
+
sessionId: providerCacheKey,
|
|
565
|
+
transport: options?.transport ?? "auto",
|
|
566
|
+
});
|
|
567
|
+
for await (const event of upstream) {
|
|
568
|
+
if (event.type === "error") {
|
|
569
|
+
const error = failure(event.error.usage, event.reason === "aborted", event.error);
|
|
570
|
+
events.push({ ...event, error });
|
|
571
|
+
events.end(error);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
events.push(event);
|
|
575
|
+
}
|
|
576
|
+
events.end(await upstream.result());
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
const aborted = (options?.signal ?? signal).aborted;
|
|
580
|
+
const error = failure({
|
|
581
|
+
input: 0,
|
|
582
|
+
output: 0,
|
|
583
|
+
cacheRead: 0,
|
|
584
|
+
cacheWrite: 0,
|
|
585
|
+
totalTokens: 0,
|
|
586
|
+
cost: {
|
|
587
|
+
input: 0,
|
|
588
|
+
output: 0,
|
|
589
|
+
cacheRead: 0,
|
|
590
|
+
cacheWrite: 0,
|
|
591
|
+
total: 0,
|
|
592
|
+
},
|
|
593
|
+
}, aborted);
|
|
594
|
+
events.push({
|
|
595
|
+
type: "error",
|
|
596
|
+
reason: aborted ? "aborted" : "error",
|
|
597
|
+
error,
|
|
598
|
+
});
|
|
599
|
+
events.end(error);
|
|
600
|
+
}
|
|
601
|
+
})();
|
|
602
|
+
return events;
|
|
603
|
+
},
|
|
604
|
+
release: () => Promise.resolve(),
|
|
605
|
+
}));
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
/** One local Computer source, resolving its PATH and process scope per Session. */
|
|
610
|
+
function createPrivateLocalComputerSource(root, port, sessionPath, processes) {
|
|
611
|
+
const id = "local-computer";
|
|
612
|
+
return {
|
|
613
|
+
id,
|
|
614
|
+
acquire: async (context) => {
|
|
615
|
+
const sessionId = context.session?.sessionId;
|
|
616
|
+
if (sessionId === undefined)
|
|
617
|
+
throw new Error("Local Computer requires a Session");
|
|
618
|
+
const path = sessionPath(sessionId) ??
|
|
619
|
+
capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd());
|
|
620
|
+
const selected = await createPrivateInvocationComputer(root, path, processes.scope(sessionId));
|
|
621
|
+
return Knapsack.computer({
|
|
622
|
+
id,
|
|
623
|
+
name: "local",
|
|
624
|
+
namespace: ["computers"],
|
|
625
|
+
files: {
|
|
626
|
+
port,
|
|
627
|
+
pathInstructions: localPathInstructions(root),
|
|
628
|
+
},
|
|
629
|
+
computer: selected,
|
|
630
|
+
}).acquire(context);
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
function localPathInstructions(root) {
|
|
635
|
+
return `Files root: ${root}. Relative paths resolve from this root; absolute paths must remain inside it.`;
|
|
636
|
+
}
|
|
637
|
+
/** Assemble the existing Files, Computer, and workerd owners once per host. */
|
|
638
|
+
export async function createPrivateLocalTools(invocationRoot, fungiMcp, agentControl, sessionPath,
|
|
639
|
+
/** This Agent's store; its owner-enabled installed extensions join the roster. */
|
|
640
|
+
storeDirectory,
|
|
641
|
+
/**
|
|
642
|
+
* This Agent's sub-agent record: its Session-bound Agent-control tools and
|
|
643
|
+
* the view's facts. Present when it can direct other Agents.
|
|
644
|
+
*/
|
|
645
|
+
subagents) {
|
|
646
|
+
const root = await realpath(resolve(invocationRoot));
|
|
647
|
+
const port = await createPrivateInvocationFilesPort(root);
|
|
648
|
+
const processes = createPrivateLocalComputerProcesses(root);
|
|
649
|
+
const computerSources = sessionPath === undefined
|
|
650
|
+
? [
|
|
651
|
+
Knapsack.computer({
|
|
652
|
+
id: "local-computer",
|
|
653
|
+
name: "local",
|
|
654
|
+
namespace: ["computers"],
|
|
655
|
+
files: {
|
|
656
|
+
port,
|
|
657
|
+
pathInstructions: localPathInstructions(root),
|
|
658
|
+
},
|
|
659
|
+
computer: await createPrivateInvocationComputer(root, undefined, processes.scope("invocation")),
|
|
660
|
+
}),
|
|
661
|
+
]
|
|
662
|
+
: [createPrivateLocalComputerSource(root, port, sessionPath, processes)];
|
|
663
|
+
const base = botanicalExtensionsWithSkills(createLocalSkillSource({ cwd: root }));
|
|
664
|
+
const installed = storeDirectory === undefined
|
|
665
|
+
? { roster: [], grants: [], diagnostics: [] }
|
|
666
|
+
: await localExtensions().agent(storeDirectory, root);
|
|
667
|
+
for (const diagnostic of installed.diagnostics)
|
|
668
|
+
logEvent("local-extension-load", diagnostic.error);
|
|
669
|
+
const extensions = {
|
|
670
|
+
roster: [
|
|
671
|
+
...base.roster,
|
|
672
|
+
homeExtension({
|
|
673
|
+
computers: {
|
|
674
|
+
read: () => [{ name: "local", attachmentId: "local-computer", port }],
|
|
675
|
+
},
|
|
676
|
+
}),
|
|
677
|
+
...(subagents === undefined
|
|
678
|
+
? []
|
|
679
|
+
: [subagentsExtension({ source: subagents.source })]),
|
|
680
|
+
...installed.roster,
|
|
681
|
+
],
|
|
682
|
+
grants: [
|
|
683
|
+
...base.grants,
|
|
684
|
+
homeGrant,
|
|
685
|
+
...(subagents === undefined ? [] : [subagentsGrant]),
|
|
686
|
+
...installed.grants,
|
|
687
|
+
],
|
|
688
|
+
};
|
|
689
|
+
return Object.freeze({
|
|
690
|
+
port,
|
|
691
|
+
extensions,
|
|
692
|
+
processes,
|
|
693
|
+
tools: Object.freeze({
|
|
694
|
+
execution: Object.freeze({ timeoutMs: 900_000 }),
|
|
695
|
+
sources: Object.freeze([
|
|
696
|
+
...(agentControl === undefined
|
|
697
|
+
? []
|
|
698
|
+
: [subagents?.control ?? createAgentControlSource(agentControl)]),
|
|
699
|
+
...computerSources,
|
|
700
|
+
...(fungiMcp === undefined
|
|
701
|
+
? []
|
|
702
|
+
: [createPrivateFungiMcpConnection(fungiMcp)]),
|
|
703
|
+
]),
|
|
704
|
+
sandbox: createPrivateWorkerdSandbox(),
|
|
705
|
+
}),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
/** Shiitake's public Node runtime host, reporting failed passes to local logs. */
|
|
709
|
+
function localRuntimeOwner() {
|
|
710
|
+
return nodeRuntimeHost((cause) => {
|
|
711
|
+
logEvent("shiitake_reconciliation_failed", cause);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/** Event-loop adaptation for the one local Shiitake owner; it has no queue policy. */
|
|
715
|
+
export function createPrivateLocalRuntimeHost() {
|
|
716
|
+
return localRuntimeOwner().runtime;
|
|
717
|
+
}
|
|
718
|
+
function asLocalFailure(cause, message) {
|
|
719
|
+
return cause instanceof Error ? cause : new Error(message, { cause });
|
|
720
|
+
}
|
|
721
|
+
function aggregateLocalFailures(failures, message) {
|
|
722
|
+
if (failures.length === 1) {
|
|
723
|
+
const [failure] = failures;
|
|
724
|
+
if (failure === undefined)
|
|
725
|
+
throw new Error("Missing local close failure");
|
|
726
|
+
return failure;
|
|
727
|
+
}
|
|
728
|
+
return new AggregateError(failures, message);
|
|
729
|
+
}
|
|
730
|
+
async function captureCloseFailure(close, message) {
|
|
731
|
+
try {
|
|
732
|
+
await close();
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
catch (cause) {
|
|
736
|
+
return asLocalFailure(cause, message);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Close the fixed local owner in dependency order without surrendering its
|
|
741
|
+
* sidecar before runtime drain and main-database close have succeeded.
|
|
742
|
+
*
|
|
743
|
+
* This source-local seam is intentionally not re-exported by the package; the
|
|
744
|
+
* focused tests use it to falsify injected failure ordering.
|
|
745
|
+
*/
|
|
746
|
+
export async function closePrivateLocalResources(resources, initiatingFailure) {
|
|
747
|
+
const failures = initiatingFailure === undefined ? [] : [initiatingFailure];
|
|
748
|
+
if (resources.agent !== undefined) {
|
|
749
|
+
const failure = await captureCloseFailure(resources.agent.close, "Local Agent close failed");
|
|
750
|
+
if (failure !== undefined)
|
|
751
|
+
failures.push(failure);
|
|
752
|
+
}
|
|
753
|
+
// No tool call runs after the Agent closed: stop its background processes.
|
|
754
|
+
if (resources.processes !== undefined) {
|
|
755
|
+
const failure = await captureCloseFailure(resources.processes.close, "Local Computer processes close failed");
|
|
756
|
+
if (failure !== undefined)
|
|
757
|
+
failures.push(failure);
|
|
758
|
+
}
|
|
759
|
+
const runtimeFailure = await captureCloseFailure(resources.runtime.close, "Local runtime close failed");
|
|
760
|
+
if (runtimeFailure !== undefined) {
|
|
761
|
+
failures.push(runtimeFailure);
|
|
762
|
+
throw aggregateLocalFailures(failures, "Local host close failed");
|
|
763
|
+
}
|
|
764
|
+
const providerCacheKey = resources.providerCacheKey;
|
|
765
|
+
if (providerCacheKey !== undefined) {
|
|
766
|
+
const modelFailure = await captureCloseFailure(() => Promise.resolve().then(() => {
|
|
767
|
+
cleanupSessionResources(providerCacheKey);
|
|
768
|
+
}), "Local provider resources close failed");
|
|
769
|
+
if (modelFailure !== undefined)
|
|
770
|
+
failures.push(modelFailure);
|
|
771
|
+
}
|
|
772
|
+
if (resources.database !== undefined) {
|
|
773
|
+
const databaseFailure = await captureCloseFailure(() => Promise.resolve().then(() => resources.database?.close()), "Local Agent database close failed");
|
|
774
|
+
if (databaseFailure !== undefined) {
|
|
775
|
+
failures.push(databaseFailure);
|
|
776
|
+
throw aggregateLocalFailures(failures, "Local host close failed");
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (resources.lease !== undefined) {
|
|
780
|
+
const leaseFailure = await captureCloseFailure(() => Promise.resolve().then(() => resources.lease?.release()), "Local ownership sidecar release failed");
|
|
781
|
+
if (leaseFailure !== undefined)
|
|
782
|
+
failures.push(leaseFailure);
|
|
783
|
+
}
|
|
784
|
+
if (failures.length > 0)
|
|
785
|
+
throw aggregateLocalFailures(failures, "Local host close failed");
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Open one private file-backed local Shiitake host and its fixed Session.
|
|
789
|
+
*
|
|
790
|
+
* The returned close owns agent, main database, and sidecar release in that
|
|
791
|
+
* order. It is the only lifecycle the current terminal caller needs to learn.
|
|
792
|
+
*/
|
|
793
|
+
export async function openPrivateLocalRuntimeHost(input, models = createPrivateModels()) {
|
|
794
|
+
const root = await realpath(resolve(input.invocationRoot));
|
|
795
|
+
await ensurePrivateLocalGitExclude(root);
|
|
796
|
+
const selectedProviderId = input.providerId;
|
|
797
|
+
const selectedModelId = input.modelId;
|
|
798
|
+
if (selectedProviderId !== undefined && selectedProviderId !== "fungi") {
|
|
799
|
+
const model = selectedModelId === undefined
|
|
800
|
+
? undefined
|
|
801
|
+
: models.getModel(selectedProviderId, selectedModelId);
|
|
802
|
+
if (model === undefined)
|
|
803
|
+
throw new ShiitakeError("model", "rejected", `Local model is not registered: ${selectedProviderId}/${selectedModelId ?? "select with --model"}`);
|
|
804
|
+
validateLocalReasoning(model, input.reasoning);
|
|
805
|
+
}
|
|
806
|
+
const paths = await localDatabasePaths(root, input.databasePath, input.createRequestId);
|
|
807
|
+
const runtime = localRuntimeOwner();
|
|
808
|
+
let lease;
|
|
809
|
+
let providerCacheKey;
|
|
810
|
+
let database;
|
|
811
|
+
let agent;
|
|
812
|
+
let processes;
|
|
813
|
+
const sessionPaths = new Map();
|
|
814
|
+
try {
|
|
815
|
+
lease = acquireNodeStoreLease(paths.sidecarPath);
|
|
816
|
+
// Claim only after the lease: a rejected opener must not clean up its live owner.
|
|
817
|
+
providerCacheKey = createHash("sha256")
|
|
818
|
+
.update(JSON.stringify(["fungi-local", paths.databasePath, initialSessionId]))
|
|
819
|
+
.digest("hex");
|
|
820
|
+
database = new DatabaseSync(paths.databasePath, { timeout: 0 });
|
|
821
|
+
database.exec("PRAGMA foreign_keys = ON");
|
|
822
|
+
const agentId = ensureLocalAgentId(database, paths.agentId);
|
|
823
|
+
const modelDatabase = database;
|
|
824
|
+
modelDatabase.exec("CREATE TABLE IF NOT EXISTS private_local_model_settings (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), model TEXT NOT NULL) STRICT");
|
|
825
|
+
let initialModel = selectedProviderId === undefined || selectedModelId === undefined
|
|
826
|
+
? null
|
|
827
|
+
: parseModelPreference({
|
|
828
|
+
providerId: selectedProviderId,
|
|
829
|
+
modelId: selectedModelId,
|
|
830
|
+
});
|
|
831
|
+
if (initialModel !== null && input.reasoning !== undefined)
|
|
832
|
+
initialModel = parseModelPreference({
|
|
833
|
+
...initialModel,
|
|
834
|
+
reasoning: input.reasoning,
|
|
835
|
+
});
|
|
836
|
+
modelDatabase
|
|
837
|
+
.prepare("INSERT OR IGNORE INTO private_local_model_settings VALUES (1, ?)")
|
|
838
|
+
.run(JSON.stringify(initialModel));
|
|
839
|
+
const readAgentModel = () => {
|
|
840
|
+
const row = modelDatabase
|
|
841
|
+
.prepare("SELECT model FROM private_local_model_settings WHERE singleton = 1")
|
|
842
|
+
.get();
|
|
843
|
+
return parseModelPreferenceJson(row?.model);
|
|
844
|
+
};
|
|
845
|
+
const modelSettings = createLocalModelSettings(input.modelSettingsPath);
|
|
846
|
+
const store = createPrivateSqliteOwner(database);
|
|
847
|
+
let acceptMail;
|
|
848
|
+
let readSettlement;
|
|
849
|
+
if (input.mail !== undefined && input.agentControl === undefined)
|
|
850
|
+
throw new Error("Local delegation requires the project Agent directory");
|
|
851
|
+
const delegation = input.mail === undefined || input.agentControl === undefined
|
|
852
|
+
? undefined
|
|
853
|
+
: createPrivateLocalDelegation({
|
|
854
|
+
agentId,
|
|
855
|
+
storage: store,
|
|
856
|
+
directory: input.agentControl,
|
|
857
|
+
routing: input.mail,
|
|
858
|
+
accept: (mail, meta, reply) => {
|
|
859
|
+
if (acceptMail === undefined)
|
|
860
|
+
return Promise.reject(new Error("Local mail ingress is unavailable"));
|
|
861
|
+
return acceptMail(mail, meta, reply);
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
let agentControl;
|
|
865
|
+
if (input.agentControl !== undefined) {
|
|
866
|
+
agentControl = {
|
|
867
|
+
...input.agentControl,
|
|
868
|
+
models: (signal) => listOfferedLocalModels(models, signal),
|
|
869
|
+
};
|
|
870
|
+
if (delegation !== undefined)
|
|
871
|
+
agentControl = { ...agentControl, send: delegation.send };
|
|
872
|
+
}
|
|
873
|
+
const subagents = agentControl === undefined
|
|
874
|
+
? undefined
|
|
875
|
+
: createPrivateLocalSubagents({ database, directory: agentControl });
|
|
876
|
+
const localTools = await createPrivateLocalTools(root, input.fungiMcp, agentControl, (sessionId) => sessionPaths.get(sessionId), dirname(paths.databasePath), subagents);
|
|
877
|
+
processes = localTools.processes;
|
|
878
|
+
const sessionProcesses = processes;
|
|
879
|
+
const modelSource = createPrivateLocalModelSource(models, selectedProviderId, selectedModelId, providerCacheKey, input.reasoning, { agent: readAgentModel, manual: modelSettings.read });
|
|
880
|
+
const hostOptions = {
|
|
881
|
+
store,
|
|
882
|
+
runtime: runtime.runtime,
|
|
883
|
+
extensions: localTools.extensions,
|
|
884
|
+
model: modelSource,
|
|
885
|
+
instructions: createPrivateLocalInstructionSource(localTools.port),
|
|
886
|
+
tools: localTools.tools,
|
|
887
|
+
};
|
|
888
|
+
if (input.mail === undefined) {
|
|
889
|
+
agent = await Shiitake.make(hostOptions);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
const mailRoom = createPrivateLocalMailRoom(database, {
|
|
893
|
+
deliver: input.mail.deliver,
|
|
894
|
+
scheduleWake: runtime.scheduleWake,
|
|
895
|
+
});
|
|
896
|
+
const host = await makeHost(hostOptions, mailRoom);
|
|
897
|
+
agent = host.agent;
|
|
898
|
+
acceptMail = host.acceptMail;
|
|
899
|
+
readSettlement = host.readSettlement;
|
|
900
|
+
}
|
|
901
|
+
const sessionOwner = agent;
|
|
902
|
+
const getSession = (sessionId) => {
|
|
903
|
+
const session = sessionOwner.session(sessionId);
|
|
904
|
+
return {
|
|
905
|
+
...session,
|
|
906
|
+
modelPreference: {
|
|
907
|
+
read: session.modelPreference.read,
|
|
908
|
+
set: async (input) => {
|
|
909
|
+
const preference = parseModelPreference(input);
|
|
910
|
+
if (preference !== null)
|
|
911
|
+
await modelSource.assertReady(preference, new AbortController().signal);
|
|
912
|
+
await session.modelPreference.set(preference);
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
};
|
|
917
|
+
const session = getSession(initialSessionId);
|
|
918
|
+
let closing;
|
|
919
|
+
return Object.freeze({
|
|
920
|
+
agentId,
|
|
921
|
+
databasePath: paths.databasePath,
|
|
922
|
+
session,
|
|
923
|
+
getSession,
|
|
924
|
+
setModel: async (input, signal) => {
|
|
925
|
+
const preference = parseModelPreference(input);
|
|
926
|
+
if (preference === null)
|
|
927
|
+
throw new ShiitakeError("model", "invalid", "An Agent model is required");
|
|
928
|
+
await modelSource.assertReady(preference, signal);
|
|
929
|
+
signal.throwIfAborted();
|
|
930
|
+
modelDatabase
|
|
931
|
+
.prepare("UPDATE private_local_model_settings SET model = ? WHERE singleton = 1")
|
|
932
|
+
.run(JSON.stringify(preference));
|
|
933
|
+
},
|
|
934
|
+
assertModelReady: async (sessionId, signal) => {
|
|
935
|
+
const preference = await sessionOwner
|
|
936
|
+
.session(sessionId)
|
|
937
|
+
.modelPreference.read();
|
|
938
|
+
await modelSource.assertReady(preference, signal);
|
|
939
|
+
},
|
|
940
|
+
bindSessionPath: (sessionId, path) => {
|
|
941
|
+
const selected = parsePrivateLocalExecPath(path);
|
|
942
|
+
const previous = sessionPaths.get(sessionId);
|
|
943
|
+
if (previous !== undefined && previous !== selected)
|
|
944
|
+
return false;
|
|
945
|
+
sessionPaths.set(sessionId, selected);
|
|
946
|
+
return true;
|
|
947
|
+
},
|
|
948
|
+
effectiveModel: async (sessionId, signal) => {
|
|
949
|
+
signal.throwIfAborted();
|
|
950
|
+
const preference = await sessionOwner
|
|
951
|
+
.session(sessionId)
|
|
952
|
+
.modelPreference.read();
|
|
953
|
+
return modelSource.effective(preference, signal);
|
|
954
|
+
},
|
|
955
|
+
listSessions: (after) => sessionOwner.listSessions(after),
|
|
956
|
+
createSession: (requestId) => sessionOwner.createSession(requestId),
|
|
957
|
+
deleteSession: async (sessionId) => {
|
|
958
|
+
const deleted = await sessionOwner.deleteSession(sessionId);
|
|
959
|
+
// The Session ended: its background processes and logs go with it.
|
|
960
|
+
await sessionProcesses.disposeScope(sessionId);
|
|
961
|
+
return deleted;
|
|
962
|
+
},
|
|
963
|
+
extensions: sessionOwner.extensions,
|
|
964
|
+
acceptMail: (mail) => delegation === undefined
|
|
965
|
+
? Promise.reject(new Error("Local mail ingress is unavailable"))
|
|
966
|
+
: delegation.receive(mail),
|
|
967
|
+
readSettlement: (sessionId, commandId) => readSettlement === undefined
|
|
968
|
+
? Promise.reject(new Error("Local settlement reads are unavailable"))
|
|
969
|
+
: readSettlement(sessionId, commandId),
|
|
970
|
+
close: () => {
|
|
971
|
+
closing ??= closePrivateLocalResources({
|
|
972
|
+
agent,
|
|
973
|
+
processes,
|
|
974
|
+
runtime,
|
|
975
|
+
database,
|
|
976
|
+
lease,
|
|
977
|
+
providerCacheKey,
|
|
978
|
+
});
|
|
979
|
+
return closing;
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
catch (cause) {
|
|
984
|
+
const openingFailure = asLocalFailure(cause, "Local host opening failed");
|
|
985
|
+
await closePrivateLocalResources({ agent, processes, runtime, database, lease, providerCacheKey }, openingFailure);
|
|
986
|
+
throw openingFailure;
|
|
987
|
+
}
|
|
988
|
+
}
|