@fungi.computer/truffle 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +282 -34
  2. package/dist/auth-cli.d.ts +10 -0
  3. package/dist/auth-cli.js +36 -14
  4. package/dist/fungi-account-command.d.ts +1 -1
  5. package/dist/fungi-account-command.js +6 -3
  6. package/dist/index.d.ts +10 -2
  7. package/dist/index.js +8 -2
  8. package/dist/internal/image-fit-metafile.json +106 -0
  9. package/dist/internal/local-image-fit-worker.mjs +960 -0
  10. package/dist/internal/private-workerd-sandbox-worker.mjs +11 -12
  11. package/dist/internal/sandbox-metafile.json +7 -7
  12. package/dist/local-archive-handoff.d.ts +12 -0
  13. package/dist/local-archive-handoff.js +21 -0
  14. package/dist/local-extensions.d.ts +58 -12
  15. package/dist/local-extensions.js +195 -55
  16. package/dist/local-image-fit.d.ts +30 -0
  17. package/dist/local-image-fit.js +105 -0
  18. package/dist/local-models.d.ts +4 -2
  19. package/dist/local-models.js +11 -6
  20. package/dist/local-session-archives.d.ts +8 -0
  21. package/dist/local-session-archives.js +152 -0
  22. package/dist/local-skill-source.d.ts +18 -3
  23. package/dist/local-skill-source.js +216 -140
  24. package/dist/log-event.d.ts +7 -0
  25. package/dist/log-event.js +8 -4
  26. package/dist/private-bounded-response.d.ts +2 -0
  27. package/dist/private-bounded-response.js +41 -0
  28. package/dist/private-file-credential-store.d.ts +28 -3
  29. package/dist/private-file-credential-store.js +57 -9
  30. package/dist/private-files-patch.d.ts +2 -1
  31. package/dist/private-files-patch.js +21 -3
  32. package/dist/private-fungi-account.d.ts +40 -23
  33. package/dist/private-fungi-account.js +201 -21
  34. package/dist/private-fungi-mcp-connection.d.ts +4 -0
  35. package/dist/private-fungi-mcp-connection.js +25 -6
  36. package/dist/private-fungi-mcp-selection-schema.d.ts +7 -0
  37. package/dist/private-fungi-mcp-selection-schema.js +15 -0
  38. package/dist/private-fungi-mcp-selection.d.ts +6 -6
  39. package/dist/private-fungi-mcp-selection.js +29 -16
  40. package/dist/private-fungi-model-api.js +4 -4
  41. package/dist/private-fungi-model-catalog.d.ts +10 -0
  42. package/dist/private-fungi-model-catalog.js +10 -2
  43. package/dist/private-fungi-model-provider.d.ts +1 -1
  44. package/dist/private-fungi-model-provider.js +6 -1
  45. package/dist/private-fungi-pending-logouts.d.ts +1 -1
  46. package/dist/private-fungi-pending-logouts.js +1 -2
  47. package/dist/private-fungi-service-logout.d.ts +44 -0
  48. package/dist/private-fungi-service-logout.js +59 -0
  49. package/dist/private-invocation-computer.d.ts +24 -3
  50. package/dist/private-invocation-computer.js +101 -62
  51. package/dist/private-invocation-files.d.ts +16 -1
  52. package/dist/private-invocation-files.js +154 -83
  53. package/dist/private-local-acp-stdio.js +3 -0
  54. package/dist/private-local-admission-gate.d.ts +14 -0
  55. package/dist/private-local-admission-gate.js +52 -0
  56. package/dist/private-local-agent-control.d.ts +101 -0
  57. package/dist/private-local-agent-control.js +30 -0
  58. package/dist/private-local-agent-default-model.d.ts +12 -0
  59. package/dist/private-local-agent-default-model.js +31 -0
  60. package/dist/private-local-auth-rpc.d.ts +67 -7
  61. package/dist/private-local-auth-rpc.js +140 -17
  62. package/dist/private-local-bwrap.d.ts +13 -0
  63. package/dist/private-local-bwrap.js +70 -0
  64. package/dist/private-local-client-version.d.ts +5 -0
  65. package/dist/private-local-client-version.js +43 -0
  66. package/dist/private-local-computer-attach.d.ts +3 -3
  67. package/dist/private-local-computer-attach.js +17 -3
  68. package/dist/private-local-computer-processes.d.ts +6 -3
  69. package/dist/private-local-computer-processes.js +55 -31
  70. package/dist/private-local-config-root.d.ts +21 -0
  71. package/dist/private-local-config-root.js +19 -0
  72. package/dist/private-local-containment.d.ts +51 -0
  73. package/dist/private-local-containment.js +161 -0
  74. package/dist/private-local-delegation.d.ts +10 -3
  75. package/dist/private-local-delegation.js +70 -15
  76. package/dist/private-local-effective-model.d.ts +9 -1
  77. package/dist/private-local-effective-model.js +3 -0
  78. package/dist/private-local-exec-environment.d.ts +16 -0
  79. package/dist/private-local-exec-environment.js +11 -0
  80. package/dist/private-local-linux-sandbox.d.ts +8 -0
  81. package/dist/private-local-linux-sandbox.js +67 -0
  82. package/dist/private-local-mail.d.ts +4 -2
  83. package/dist/private-local-mail.js +41 -33
  84. package/dist/private-local-mesh-enrollment.d.ts +34 -0
  85. package/dist/private-local-mesh-enrollment.js +188 -0
  86. package/dist/private-local-mesh-links.d.ts +21 -0
  87. package/dist/private-local-mesh-links.js +229 -0
  88. package/dist/private-local-mesh-record.d.ts +120 -0
  89. package/dist/private-local-mesh-record.js +55 -0
  90. package/dist/private-local-process-identity.d.ts +20 -0
  91. package/dist/private-local-process-identity.js +40 -0
  92. package/dist/private-local-runtime-host.d.ts +64 -13
  93. package/dist/private-local-runtime-host.js +361 -172
  94. package/dist/private-local-sandbox-policy.d.ts +21 -0
  95. package/dist/private-local-sandbox-policy.js +121 -0
  96. package/dist/private-local-sandbox.d.ts +37 -0
  97. package/dist/private-local-sandbox.js +114 -0
  98. package/dist/private-local-seatbelt.d.ts +24 -0
  99. package/dist/private-local-seatbelt.js +68 -0
  100. package/dist/private-local-service-contract.d.ts +91 -11
  101. package/dist/private-local-service-discovery.d.ts +129 -13
  102. package/dist/private-local-service-discovery.js +148 -47
  103. package/dist/private-local-service-lease.d.ts +16 -0
  104. package/dist/private-local-service-lease.js +105 -0
  105. package/dist/private-local-service-listener.d.ts +9 -2
  106. package/dist/private-local-service-listener.js +260 -64
  107. package/dist/private-local-service-log.d.ts +128 -0
  108. package/dist/private-local-service-log.js +321 -0
  109. package/dist/private-local-service-owner.d.ts +87 -1519
  110. package/dist/private-local-service-owner.js +1069 -71
  111. package/dist/private-local-service-process.js +3 -1
  112. package/dist/private-local-service-termination.d.ts +7 -0
  113. package/dist/private-local-service-termination.js +67 -0
  114. package/dist/private-local-service.d.ts +31 -2
  115. package/dist/private-local-service.js +402 -126
  116. package/dist/private-local-session-discovery.d.ts +52 -5
  117. package/dist/private-local-session-discovery.js +132 -11
  118. package/dist/private-local-shiitake-host.js +2 -2
  119. package/dist/private-local-startup-model.d.ts +51 -0
  120. package/dist/private-local-startup-model.js +86 -0
  121. package/dist/private-local-stop-all.d.ts +27 -0
  122. package/dist/private-local-stop-all.js +26 -0
  123. package/dist/private-local-ui-attachment.d.ts +19 -0
  124. package/dist/private-local-ui-attachment.js +71 -0
  125. package/dist/private-local-worker-server.d.ts +1 -0
  126. package/dist/private-local-worker-server.js +13 -5
  127. package/dist/private-mcp-form-contract.d.ts +186 -0
  128. package/dist/private-mcp-form-contract.js +53 -0
  129. package/dist/private-mcp-form-elicitation.d.ts +18 -0
  130. package/dist/private-mcp-form-elicitation.js +97 -0
  131. package/dist/private-mcp-form-session.d.ts +172 -0
  132. package/dist/private-mcp-form-session.js +148 -0
  133. package/dist/private-openai-claims.d.ts +30 -0
  134. package/dist/private-openai-claims.js +69 -0
  135. package/dist/private-oyster-mcp-auth.d.ts +13 -0
  136. package/dist/private-oyster-mcp-auth.js +57 -0
  137. package/dist/private-provider-rejection.d.ts +15 -0
  138. package/dist/private-provider-rejection.js +24 -0
  139. package/dist/private-sqlite-owner.d.ts +34 -2
  140. package/dist/private-sqlite-owner.js +103 -37
  141. package/dist/private-team-computers.d.ts +4 -4
  142. package/dist/private-team-computers.js +11 -2
  143. package/dist/seatbelt/safe-base.sbpl +79 -0
  144. package/dist/subscription-activation.d.ts +9 -0
  145. package/dist/subscription-activation.js +62 -0
  146. package/dist/subscription-identity.d.ts +23 -0
  147. package/dist/subscription-identity.js +124 -0
  148. package/dist/subscription-turn.d.ts +17 -0
  149. package/dist/subscription-turn.js +143 -0
  150. package/dist/team-apps.d.ts +2 -2
  151. package/dist/team-apps.js +7 -2
  152. package/native/bin/LICENSE.bubblewrap +502 -0
  153. package/native/bin/LICENSE.libcap +112 -0
  154. package/native/bin/LICENSE.sandbox-runtime +201 -0
  155. package/native/bin/SOURCE.bubblewrap.tar.xz +0 -0
  156. package/native/bin/bwrap-build-source/Dockerfile +48 -0
  157. package/native/bin/bwrap-build-source/README.md +96 -0
  158. package/native/bin/bwrap-build-source/build.mjs +52 -0
  159. package/native/bin/bwrap-build-source/linux-arm64.ini +14 -0
  160. package/native/bin/bwrap-build-source/pin.mjs +21 -0
  161. package/native/bin/bwrap-build-source/record.mjs +74 -0
  162. package/native/bin/bwrap-build-source/vendor/LICENSE.sandbox-runtime +201 -0
  163. package/native/bin/bwrap-build-source/vendor/README.md +23 -0
  164. package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +280 -0
  165. package/native/bin/bwrap-build.json +44 -0
  166. package/native/bin/fungi-bwrap +0 -0
  167. package/native/bin/fungi-bwrap.sha256 +1 -0
  168. package/native/bin/fungi-seccomp.bpf +0 -0
  169. package/native/bin/fungi-seccomp.bpf.sha256 +1 -0
  170. package/package.json +44 -31
  171. package/dist/private-local-model-settings.d.ts +0 -5
  172. package/dist/private-local-model-settings.js +0 -31
  173. package/dist/private-local-subagents.d.ts +0 -27
  174. package/dist/private-local-subagents.js +0 -77
@@ -1,45 +1,280 @@
1
+ import * as Effect from "effect/Effect";
1
2
  import { logEvent } from "./log-event.js";
2
- import { readFungiMcpSelection, sameFungiMcpConnection, FungiMcpSelectionConflict, } from "./private-fungi-mcp-selection.js";
3
+ import { createPrivateModels } from "./local-models.js";
4
+ import { capturePrivateLocalConfigRoot, } from "./private-local-config-root.js";
5
+ import { AdmissionGate } from "./private-local-admission-gate.js";
6
+ import { readFungiMcpSelection, readProjectFungiMcpSelection, sameFungiMcpConnection, FungiMcpSelectionConflict, } from "./private-fungi-mcp-selection.js";
3
7
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { AgentCreateRefused, AgentArchiveRefused, } from "@fungi.computer/shiitake/agent-control";
10
+ import { localExtensions, LocalExtensionError, } from "./local-extensions.js";
4
11
  import { readFile, realpath } from "node:fs/promises";
5
12
  import writeFileAtomic from "write-file-atomic";
6
13
  import { z } from "zod";
7
14
  import * as Schema from "effect/Schema";
8
15
  import { selectionSchema } from "./private-local-service-discovery.js";
9
16
  import { createShiitakeHttpRoutes } from "@fungi.computer/shiitake/http";
10
- import { listPrivateLocalAgentStores, readPrivateLocalSessionSummaries, } from "./private-local-session-discovery.js";
17
+ import { listPrivateLocalAgentStores, LocalStoreSchemaError, readPrivateLocalAgentProfile, readPrivateLocalSessionSummaries, isArchivedStore, unreadableStoreReason, } from "./private-local-session-discovery.js";
11
18
  import { createPrivateLocalSessionRequestId, openPrivateLocalRuntimeHost, resolvePrivateLocalCreateStore, } from "./private-local-runtime-host.js";
19
+ import { parsePrivateLocalExecPath } from "./private-local-exec-path.js";
20
+ import { createLocalMeshLinks, localMeshLinkKey, } from "./private-local-mesh-links.js";
21
+ import { agentMailAddressSchema, machineAgentRequestSchema, machineDirectorySchema, } from "@fungi.computer/seam/machine-link-protocol";
22
+ import { parseAccepted } from "@fungi.computer/shiitake/client";
23
+ import { commandSettlementSchema } from "@fungi.computer/shiitake/fleet";
24
+ import { BirdDogMailSchema } from "@fungi.computer/bird-dog/effect";
12
25
  export class LocalSessionPathConflict extends Error {
13
26
  name = "LocalSessionPathConflict";
14
27
  constructor() {
15
28
  super("PATH differs for this live session; close its clients and restart the Local service before reconnecting from another toolchain");
16
29
  }
17
30
  }
31
+ /**
32
+ * A folder holds several Agent stores and the request chose none of them.
33
+ * The service never picks one for the caller; --agent, --db or the
34
+ * landing screen does.
35
+ */
36
+ export class LocalAgentSelectionAmbiguous extends Error {
37
+ name = "LocalAgentSelectionAmbiguous";
38
+ agentIds;
39
+ constructor(agentIds) {
40
+ super(`Several Agents in this folder (${agentIds.join(", ")}); choose one with --agent <id> or --db <path>`);
41
+ this.agentIds = agentIds;
42
+ }
43
+ }
18
44
  // Reopen inputs only. Credentials remain with Local's account custodian.
19
45
  const recoveryOwnerSchema = selectionSchema
20
- .omit({ clientPath: true, sessionId: true })
46
+ .omit({ clientPath: true, sessionId: true, uiAttachmentLifetime: true })
21
47
  .extend({ databasePath: z.string().min(1) });
22
48
  const decodeRecoveryFile = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Schema.Unknown)));
49
+ class LocalRecoveryPersistenceDisabled extends Error {
50
+ reason;
51
+ name = "LocalRecoveryPersistenceDisabled";
52
+ constructor(reason, cause) {
53
+ super("Recovery persistence is disabled: the manifest could not be loaded", {
54
+ cause,
55
+ });
56
+ this.reason = reason;
57
+ }
58
+ }
23
59
  /** One service owns each store, independently of how many clients select it. */
24
- export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHost, recoveryDirectory) {
60
+ export function createLocalServiceOwners(openRuntime = (input) => openPrivateLocalRuntimeHost(input, createPrivateModels(input.configRoot.credentialPath)), recoveryDirectory,
61
+ /** The installed-extension owner that decides what each Agent holds. */
62
+ suppliedExtensions,
63
+ /** How often idle Agents are checked; one is released on its second idle check. */
64
+ idleSweepMs = 60_000,
65
+ /** Read the recovery manifest; tests can hold this I/O independently of opens. */
66
+ readRecoveryManifest = (path) => readFile(path, "utf8"),
67
+ /** Report only the classification, never the manifest or exception text. */
68
+ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot()) {
69
+ const extensions = suppliedExtensions ?? localExtensions({ home: configRoot.home });
25
70
  const recoveryPath = recoveryDirectory === undefined
26
71
  ? undefined
27
72
  : resolve(recoveryDirectory, "owners.json");
28
73
  const recovery = new Map();
74
+ let loadingRecovery;
29
75
  let saving = Promise.resolve();
30
76
  const emptySessions = new Map();
31
77
  const owners = new Map();
32
- const opening = new Set();
78
+ const roots = new Map();
79
+ const meshDirectories = new Map();
80
+ async function meshAdmission(localAgentId, recipientAgentId) {
81
+ for (const { record, agents } of meshDirectories.values()) {
82
+ const binding = record.bindings.find((candidate) => candidate.agentId === recipientAgentId);
83
+ if (binding !== undefined &&
84
+ record.agents[binding.selection]?.localAgentId === localAgentId &&
85
+ (await mesh.isCurrent(record)))
86
+ return agents;
87
+ }
88
+ return undefined;
89
+ }
90
+ const mesh = createLocalMeshLinks({
91
+ credentialPath: configRoot.credentialPath,
92
+ receive: async (record, envelope) => {
93
+ if (envelope.kind === "machineDirectory") {
94
+ const address = z
95
+ .strictObject({ teamId: z.string(), deviceId: z.string() })
96
+ .parse(envelope.address);
97
+ if (address.teamId !== record.scopeId ||
98
+ address.deviceId !== record.deviceId)
99
+ return "refused";
100
+ const projection = Schema.decodeUnknownSync(machineDirectorySchema, {
101
+ onExcessProperty: "error",
102
+ })(envelope.event);
103
+ meshDirectories.set(localMeshLinkKey(record), {
104
+ record,
105
+ agents: projection.agents,
106
+ });
107
+ return "accepted";
108
+ }
109
+ const address = Schema.decodeUnknownSync(agentMailAddressSchema, {
110
+ onExcessProperty: "error",
111
+ })(envelope.address);
112
+ const binding = record.bindings.find((candidate) => candidate.agentId === address.agentId);
113
+ const selected = binding === undefined ? undefined : record.agents[binding.selection];
114
+ if (address.scopeId !== record.scopeId || selected === undefined)
115
+ return "refused";
116
+ const { owner, release } = await directory(record.invocationRoot).owner(selected.localAgentId, new AbortController().signal);
117
+ try {
118
+ if (owner.databasePath !== selected.databasePath)
119
+ return "refused";
120
+ if (envelope.kind === "machineAgent") {
121
+ const { actorAgentId, operation } = Schema.decodeUnknownSync(machineAgentRequestSchema, {
122
+ onExcessProperty: "error",
123
+ })(envelope.event);
124
+ if (operation.type === "createSession") {
125
+ const sessionId = await admitted(owner, () => owner.createSession(operation.requestId));
126
+ return { status: "accepted", receipt: { sessionId } };
127
+ }
128
+ const facts = await admitted(owner, () => owner.readSettlement(operation.sessionId, operation.commandId));
129
+ if (facts !== undefined && facts.acceptedSender !== actorAgentId)
130
+ return "refused";
131
+ return {
132
+ status: "accepted",
133
+ receipt: {
134
+ settlement: facts === undefined
135
+ ? null
136
+ : Schema.encodeSync(Schema.fromJsonString(commandSettlementSchema))(facts),
137
+ },
138
+ };
139
+ }
140
+ const mail = Schema.decodeUnknownSync(BirdDogMailSchema, {
141
+ onExcessProperty: "error",
142
+ })(envelope.event);
143
+ if (envelope.kind !== "agentMail" ||
144
+ envelope.key !== mail.key ||
145
+ mail.to.agentId !== address.agentId ||
146
+ new TextEncoder().encode(JSON.stringify(mail)).byteLength > 256 * 1024)
147
+ return "refused";
148
+ // Only select the physical store here. Never rewrite frozen addresses
149
+ // or bypass that runtime's own recipient/sender admission.
150
+ const receipt = await admitted(owner, () => owner.acceptMail(mail));
151
+ return { status: "accepted", receipt };
152
+ }
153
+ finally {
154
+ release();
155
+ }
156
+ },
157
+ });
158
+ let configuration;
159
+ let reloading;
160
+ const pendingReload = () => reloading;
161
+ async function loadConfiguration(captured, invocationRoot, storeDirectory) {
162
+ const installed = await captured.load(storeDirectory, invocationRoot);
163
+ if (installed.diagnostics.length > 0)
164
+ throw new Error(installed.diagnostics
165
+ .map(({ extensionId, error }) => `${extensionId}: ${error.message}`)
166
+ .join("\n"));
167
+ const selected = captured.mcp.has(invocationRoot)
168
+ ? captured.mcp.get(invocationRoot)
169
+ : await readProjectFungiMcpSelection(invocationRoot);
170
+ return {
171
+ installed,
172
+ fungiMcp: selected === undefined
173
+ ? undefined
174
+ : readFungiMcpSelection(selected, {
175
+ credentialPath: configRoot.credentialPath,
176
+ }),
177
+ clientPath: captured.clientPath,
178
+ };
179
+ }
180
+ function reload(invocationRoot, clientPath) {
181
+ if (reloading !== undefined)
182
+ return Promise.reject(new Error("A configuration reload is already in progress"));
183
+ const operation = (async () => {
184
+ ensureRunning();
185
+ const path = parsePrivateLocalExecPath(clientPath);
186
+ await Promise.all(pendingOpenings);
187
+ const projects = new Set([
188
+ ...roots.values(),
189
+ await realpath(invocationRoot),
190
+ ]);
191
+ const selections = new Map();
192
+ for (const project of projects)
193
+ selections.set(project, await readProjectFungiMcpSelection(project));
194
+ const candidate = {
195
+ load: await localExtensions({ home: configRoot.home }).captureAgent(),
196
+ mcp: selections,
197
+ clientPath: path,
198
+ };
199
+ const prepared = [];
200
+ const releases = [];
201
+ try {
202
+ for (const pending of new Set(owners.values())) {
203
+ const { runtime } = await pending;
204
+ releases.push(await gateFor(runtime.databasePath).lease());
205
+ const root = roots.get(runtime.databasePath);
206
+ if (root === undefined)
207
+ throw new Error("Agent configuration root is unavailable");
208
+ prepared.push(await runtime.prepareReload(await loadConfiguration(candidate, root, dirname(runtime.databasePath))));
209
+ }
210
+ ensureRunning();
211
+ for (const staged of prepared)
212
+ staged.commit();
213
+ configuration = candidate;
214
+ }
215
+ catch (cause) {
216
+ const discarded = await Promise.allSettled(prepared.map((staged) => staged.discard()));
217
+ const failures = [];
218
+ for (const result of discarded)
219
+ if (result.status === "rejected")
220
+ failures.push(result.reason);
221
+ if (failures.length > 0)
222
+ throw new AggregateError([cause, ...failures], "Configuration reload failed; candidate cleanup also failed", { cause });
223
+ throw cause;
224
+ }
225
+ finally {
226
+ for (const release of releases)
227
+ release();
228
+ // A reload's leases end here; an unused gate must not pin its Agent.
229
+ for (const databasePath of gates.keys())
230
+ pruneGate(databasePath);
231
+ }
232
+ })();
233
+ reloading = operation;
234
+ return operation.finally(() => {
235
+ reloading = undefined;
236
+ });
237
+ }
238
+ const gates = new Map();
239
+ const opening = new Map();
33
240
  const pendingOpenings = new Set();
34
241
  let stopping = false;
35
242
  let closing;
243
+ let restoring;
244
+ /** Requests, streams and operations currently using each open store. */
245
+ const uses = new Map();
246
+ /** Live event responses are observers, not work that survives orderly stop. */
247
+ const eventResponses = new Set();
248
+ /** Stores found idle by the previous sweep. */
249
+ const idleCandidates = new Set();
250
+ /** Released stores whose runtime is still closing; a reopen waits for it. */
251
+ const releasing = new Map();
252
+ const released = new WeakSet();
253
+ const openedByStore = new Map();
254
+ const routesByRuntime = new WeakMap();
255
+ let sweeping;
256
+ const sweepTimer = setInterval(() => {
257
+ void sweepIdle().catch((cause) => {
258
+ logEvent("service-owner.sweep-idle", cause);
259
+ });
260
+ }, idleSweepMs);
261
+ sweepTimer.unref();
36
262
  function saveRecovery() {
37
263
  if (recoveryPath === undefined)
38
264
  return Promise.resolve();
39
265
  const path = recoveryPath;
40
- const next = saving.then(() => writeFileAtomic(path, JSON.stringify([...recovery.values()]), {
41
- mode: 0o600,
42
- }));
266
+ const next = saving.then(async () => {
267
+ // A ready listener may admit a new Agent during the initial read. Merge
268
+ // saved references before capturing any replacement list.
269
+ const loaded = await loadRecovery();
270
+ if (loaded.kind === "disabled") {
271
+ logEvent("service-owner.save-recovery-disabled", loaded.error);
272
+ return;
273
+ }
274
+ await writeFileAtomic(path, JSON.stringify([...recovery.values()]), {
275
+ mode: 0o600,
276
+ });
277
+ });
43
278
  // A recovery hint write must not hide a live owner or poison later saves.
44
279
  saving = next.catch((cause) => {
45
280
  logEvent("service-owner.save-recovery", cause);
@@ -50,12 +285,16 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
50
285
  const opened = pending;
51
286
  // Runtime ownership survives a failed recovery-reference write. Shutdown
52
287
  // must still drain it before the service can release its owner lock.
53
- opening.add(opened);
54
- void opened.catch((cause) => {
288
+ opening.set(opened, key);
289
+ void opened.then((owner) => {
290
+ opening.set(opened, owner.runtime.agentId);
291
+ openedByStore.set(owner.runtime.databasePath, opened);
292
+ }, (cause) => {
55
293
  opening.delete(opened);
56
294
  logEvent("service-owner.open-runtime", cause);
57
295
  });
58
296
  pending = pending.then(async (owner) => {
297
+ roots.set(owner.runtime.databasePath, invocationRoot);
59
298
  if (recoveryPath !== undefined) {
60
299
  const reference = {
61
300
  invocationRoot,
@@ -83,18 +322,278 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
83
322
  });
84
323
  return pending;
85
324
  }
325
+ function gateFor(databasePath) {
326
+ let gate = gates.get(databasePath);
327
+ if (gate === undefined) {
328
+ gate = new AdmissionGate();
329
+ gates.set(databasePath, gate);
330
+ }
331
+ return gate;
332
+ }
333
+ /** Admit one piece of work to an Agent, refusing it if the Agent is archived by then. */
334
+ async function admitted(owner, admit) {
335
+ const release = await gateFor(owner.databasePath).lease();
336
+ try {
337
+ if (isArchivedStore(owner.databasePath))
338
+ throw new AgentArchiveRefused("archived", `Agent ${owner.agentId} is archived; unarchive it to work with it`);
339
+ return await admit();
340
+ }
341
+ finally {
342
+ release();
343
+ pruneGate(owner.databasePath);
344
+ }
345
+ }
346
+ /** The gate of an Agent whose last admission just ended is forgotten. */
347
+ function pruneGate(databasePath) {
348
+ if (gates.get(databasePath)?.idle === true)
349
+ gates.delete(databasePath);
350
+ }
351
+ /**
352
+ * One Session as a client may use it: work starts (a prompt, a compaction, a
353
+ * send) only through an admission that finds the Agent in use.
354
+ */
355
+ function gatedSession(owner, sessionId, attachment) {
356
+ const session = owner.getSession(sessionId, attachment);
357
+ function command(input) {
358
+ return input.type === "prompt" || input.type === "compact"
359
+ ? admitted(owner, () => session.command(input))
360
+ : session.command(input);
361
+ }
362
+ return {
363
+ ...session,
364
+ command,
365
+ send: (...args) => admitted(owner, () => session.send(...args)),
366
+ };
367
+ }
86
368
  function ensureRunning() {
87
369
  if (stopping)
88
370
  throw new Error("Local service is stopping");
89
371
  }
90
- function selected(owner, sessionId) {
372
+ /** Count one use of an open store until its release; any use resets idleness. */
373
+ function hold(databasePath) {
374
+ uses.set(databasePath, (uses.get(databasePath) ?? 0) + 1);
375
+ idleCandidates.delete(databasePath);
376
+ let held = true;
377
+ return () => {
378
+ if (!held)
379
+ return;
380
+ held = false;
381
+ const remaining = (uses.get(databasePath) ?? 1) - 1;
382
+ if (remaining > 0)
383
+ uses.set(databasePath, remaining);
384
+ else
385
+ uses.delete(databasePath);
386
+ };
387
+ }
388
+ /** An open runtime held for one use; a store released meanwhile is reopened. */
389
+ async function acquire(selection) {
390
+ for (;;) {
391
+ const runtime = await ownerFor(selection);
392
+ if (!released.has(runtime))
393
+ return { runtime, release: hold(runtime.databasePath) };
394
+ }
395
+ }
396
+ function routesFor(owner, attachment) {
397
+ const createRoutes = () => createShiitakeHttpRoutes((id) => gatedSession(owner, id, attachment), owner.listSessions, (requestId) => admitted(owner, () => owner.createSession(requestId)), owner.deleteSession, () => owner.extensions);
398
+ if (attachment !== undefined)
399
+ return createRoutes();
400
+ let routes = routesByRuntime.get(owner);
401
+ if (routes === undefined) {
402
+ routes = createRoutes();
403
+ routesByRuntime.set(owner, routes);
404
+ }
405
+ return routes;
406
+ }
407
+ /**
408
+ * Serve one client request on an Agent's mounted route, reopening a released
409
+ * Agent. A streamed body (a Session watch) keeps the Agent in use until it
410
+ * ends or the client disconnects.
411
+ */
412
+ async function serve(selection, request, attachment) {
413
+ const { runtime, release } = await acquire(selection);
414
+ const streamAbort = new AbortController();
415
+ let response;
416
+ try {
417
+ response = await routesFor(runtime, attachment).fetch(new Request(request, {
418
+ signal: AbortSignal.any([request.signal, streamAbort.signal]),
419
+ }));
420
+ }
421
+ catch (cause) {
422
+ release();
423
+ throw cause;
424
+ }
425
+ const body = response.body;
426
+ if (body === null) {
427
+ release();
428
+ return response;
429
+ }
430
+ const reader = body.getReader();
431
+ let finish = release;
432
+ let cancelling;
433
+ const cancelBody = (reason) => {
434
+ cancelling ??= reader.cancel(reason).finally(() => {
435
+ finish();
436
+ });
437
+ return cancelling;
438
+ };
439
+ if (response.headers.get("content-type")?.startsWith("text/event-stream")) {
440
+ const stop = () => {
441
+ streamAbort.abort();
442
+ // Hono on current Bun observes response cancellation, not just the
443
+ // Request signal. Join cancellation before releasing this stream's use.
444
+ return cancelBody(streamAbort.signal.reason);
445
+ };
446
+ finish = () => {
447
+ eventResponses.delete(stop);
448
+ release();
449
+ };
450
+ eventResponses.add(stop);
451
+ // An already-admitted route may finish opening its response during stop.
452
+ if (stopping)
453
+ await stop();
454
+ }
455
+ return new Response(new ReadableStream({
456
+ async pull(controller) {
457
+ try {
458
+ const chunk = await reader.read();
459
+ if (chunk.done) {
460
+ finish();
461
+ controller.close();
462
+ }
463
+ else if (chunk.value instanceof Uint8Array)
464
+ controller.enqueue(chunk.value);
465
+ else
466
+ throw new TypeError("A Local route streamed a non-byte chunk");
467
+ }
468
+ catch (cause) {
469
+ finish();
470
+ controller.error(cause);
471
+ }
472
+ },
473
+ cancel(reason) {
474
+ return cancelBody(reason);
475
+ },
476
+ }), response);
477
+ }
478
+ async function effectiveModel(selection, sessionId, signal) {
479
+ const { runtime, release } = await acquire(selection);
480
+ try {
481
+ return await runtime.effectiveModel(sessionId, signal);
482
+ }
483
+ finally {
484
+ release();
485
+ }
486
+ }
487
+ async function sandboxStatus(selection, signal) {
488
+ signal.throwIfAborted();
489
+ const { runtime, release } = await acquire(selection);
490
+ try {
491
+ signal.throwIfAborted();
492
+ return runtime.sandboxStatus();
493
+ }
494
+ finally {
495
+ release();
496
+ }
497
+ }
498
+ /**
499
+ * Close Agents nothing uses that hold no work a reopen could not resume,
500
+ * on their second consecutive idle check, and forget them for restart.
501
+ */
502
+ function sweepIdle() {
503
+ sweeping ??= (async () => {
504
+ const closed = [];
505
+ const stores = new Map();
506
+ for (const [key, pending] of owners)
507
+ if (key.startsWith("store:"))
508
+ stores.set(key.slice(6), pending);
509
+ for (const databasePath of idleCandidates)
510
+ if (!stores.has(databasePath))
511
+ idleCandidates.delete(databasePath);
512
+ for (const [databasePath, pending] of stores) {
513
+ // A configuration reload holds every open Agent it snapshotted until
514
+ // it commits; releasing one beneath it would strand that reload.
515
+ const busy = () => stopping ||
516
+ reloading !== undefined ||
517
+ uses.has(databasePath) ||
518
+ gates.has(databasePath) ||
519
+ owners.get(`store:${databasePath}`) !== pending;
520
+ if (busy()) {
521
+ idleCandidates.delete(databasePath);
522
+ continue;
523
+ }
524
+ let runtime;
525
+ let idle;
526
+ try {
527
+ ({ runtime } = await pending);
528
+ idle = await runtime.idle();
529
+ }
530
+ catch (cause) {
531
+ // One unreadable Agent stays open; the sweep still checks the rest.
532
+ logEvent("service-owner.read-idle", cause);
533
+ idleCandidates.delete(databasePath);
534
+ continue;
535
+ }
536
+ // Anything that started while idleness was read keeps the Agent open.
537
+ if (!idle || busy()) {
538
+ idleCandidates.delete(databasePath);
539
+ continue;
540
+ }
541
+ if (!idleCandidates.has(databasePath)) {
542
+ idleCandidates.add(databasePath);
543
+ continue;
544
+ }
545
+ closed.push(releaseStore(databasePath, pending, runtime));
546
+ }
547
+ await Promise.all(closed);
548
+ })().finally(() => {
549
+ sweeping = undefined;
550
+ });
551
+ return sweeping;
552
+ }
553
+ function releaseStore(databasePath, pending, runtime) {
554
+ idleCandidates.delete(databasePath);
555
+ released.add(runtime);
556
+ for (const [key, value] of owners)
557
+ if (value === pending)
558
+ owners.delete(key);
559
+ emptySessions.delete(databasePath);
560
+ roots.delete(databasePath);
561
+ recovery.delete(databasePath);
562
+ const opened = openedByStore.get(databasePath);
563
+ openedByStore.delete(databasePath);
564
+ if (opened !== undefined)
565
+ opening.delete(opened);
566
+ const closed = (async () => {
567
+ try {
568
+ await runtime.close();
569
+ }
570
+ catch (cause) {
571
+ logEvent("service-owner.release-runtime", cause);
572
+ }
573
+ await saveRecovery();
574
+ })();
575
+ releasing.set(databasePath, closed);
576
+ return closed.finally(() => {
577
+ if (releasing.get(databasePath) === closed)
578
+ releasing.delete(databasePath);
579
+ });
580
+ }
581
+ /** Settle every pending release of this store, then continue synchronously. */
582
+ async function releasedClosed(databasePath) {
583
+ for (let closed = releasing.get(databasePath); closed !== undefined; closed = releasing.get(databasePath)) {
584
+ await closed;
585
+ ensureRunning();
586
+ }
587
+ }
588
+ function selected(owner, sessionId, invocationRoot, release) {
91
589
  return {
92
590
  agentId: owner.agentId,
93
591
  databasePath: owner.databasePath,
592
+ invocationRoot,
94
593
  sessionId,
95
- session: owner.getSession(sessionId),
96
- effectiveModel: owner.effectiveModel,
97
- routes: createShiitakeHttpRoutes(owner.getSession, owner.listSessions, owner.createSession, owner.deleteSession, () => owner.extensions),
594
+ session: gatedSession(owner, sessionId),
595
+ /** End this selection's use; the mounted route reopens the Agent later. */
596
+ release,
98
597
  };
99
598
  }
100
599
  async function createSession(creation) {
@@ -108,57 +607,97 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
108
607
  const databasePath = requested === undefined
109
608
  ? undefined
110
609
  : await realpath(resolve(invocationRoot, requested));
111
- if (databasePath !== undefined || owners.has(`project:${invocationRoot}`))
610
+ if (databasePath !== undefined)
112
611
  return databasePath;
612
+ // Name the project's open Agent by its store, so a reopen after an idle
613
+ // release waits for that close like any other store selection.
614
+ const projectOwner = owners.get(`project:${invocationRoot}`);
615
+ if (projectOwner !== undefined)
616
+ return (await projectOwner).runtime.databasePath;
113
617
  const stores = await listPrivateLocalAgentStores(invocationRoot);
114
618
  ensureRunning();
115
619
  if (stores.length > 1)
116
- throw new Error("Multiple Agent stores in this folder; select a saved conversation with fungi resume or specify --database");
620
+ throw new LocalAgentSelectionAmbiguous(stores.map((store) => basename(dirname(store))));
117
621
  return stores[0];
118
622
  }
119
623
  async function ownerFor(selection) {
624
+ while (pendingReload() !== undefined)
625
+ await pendingReload();
120
626
  ensureRunning();
121
627
  const invocationRoot = await realpath(selection.invocationRoot);
122
628
  const databasePath = await selectedDatabasePath(invocationRoot, selection.databasePath);
123
629
  ensureRunning();
124
630
  const fungiMcp = selection.fungiMcp === undefined
125
631
  ? undefined
126
- : readFungiMcpSelection(selection.fungiMcp);
632
+ : readFungiMcpSelection(selection.fungiMcp, {
633
+ credentialPath: configRoot.credentialPath,
634
+ });
127
635
  ensureRunning();
128
636
  // A new project selection shares its in-flight owner; explicit resume keys the store.
129
637
  const key = databasePath === undefined
130
638
  ? `project:${invocationRoot}`
131
639
  : `store:${databasePath}`;
640
+ while (pendingReload() !== undefined)
641
+ await pendingReload();
642
+ ensureRunning();
643
+ // One process owns a store once: a reopen waits for its released close.
644
+ if (databasePath !== undefined)
645
+ await releasedClosed(databasePath);
132
646
  let pending = owners.get(key);
133
647
  if (pending === undefined && pendingOpenings.size > 0) {
134
648
  // A fresh project does not reveal its canonical store path until opening.
135
649
  // Let current openings install both project and store aliases, then recheck.
136
650
  await Promise.allSettled(pendingOpenings);
651
+ while (pendingReload() !== undefined)
652
+ await pendingReload();
137
653
  ensureRunning();
138
654
  pending = owners.get(key);
139
655
  }
656
+ const openingHere = pending === undefined;
140
657
  if (pending === undefined) {
141
658
  const projectControl = databasePath === undefined ||
142
659
  belongsToProject(invocationRoot, databasePath);
143
660
  let options = {
661
+ configRoot,
144
662
  invocationRoot,
145
663
  databasePath,
664
+ // The host must persist/admit this BEFORE Shiitake can recover work.
665
+ requireSandbox: selection.requireSandbox,
146
666
  agentControl: projectControl ? directory(invocationRoot) : undefined,
147
667
  mail: projectControl ? mailRouting(invocationRoot) : undefined,
668
+ meshAdmission,
148
669
  };
149
670
  if (fungiMcp !== undefined)
150
671
  options = { ...options, fungiMcp };
672
+ const captured = configuration;
673
+ if (captured !== undefined)
674
+ options = {
675
+ ...options,
676
+ configuration: (store) => loadConfiguration(captured, invocationRoot, store),
677
+ };
151
678
  pending = trackOpening(openRuntime(options).then((runtime) => ({ runtime, fungiMcp })), invocationRoot, key);
152
679
  }
153
680
  const owner = await pending;
154
681
  ensureRunning();
155
- if (fungiMcp !== undefined &&
682
+ if (configuration === undefined &&
683
+ fungiMcp !== undefined &&
156
684
  !sameFungiMcpConnection(owner.fungiMcp, fungiMcp))
157
685
  throw new FungiMcpSelectionConflict();
686
+ if (!openingHere && selection.requireSandbox !== undefined)
687
+ owner.runtime.setRequireSandbox(selection.requireSandbox);
158
688
  return owner.runtime;
159
689
  }
160
690
  async function select(selection, createRequestId) {
161
- const owner = await ownerFor(selection);
691
+ const { runtime: owner, release } = await acquire(selection);
692
+ try {
693
+ return selected(owner, await selectedSession(owner, selection, createRequestId), await realpath(selection.invocationRoot), release);
694
+ }
695
+ catch (cause) {
696
+ release();
697
+ throw cause;
698
+ }
699
+ }
700
+ async function selectedSession(owner, selection, createRequestId) {
162
701
  let sessionId = selection.sessionId;
163
702
  if (createRequestId !== undefined) {
164
703
  sessionId = await owner.createSession(createRequestId);
@@ -180,7 +719,7 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
180
719
  if (selection.clientPath !== undefined &&
181
720
  !owner.bindSessionPath(sessionId, selection.clientPath))
182
721
  throw new LocalSessionPathConflict();
183
- return selected(owner, sessionId);
722
+ return sessionId;
184
723
  }
185
724
  /**
186
725
  * Project mail routing for every Agent this service owns in one project.
@@ -190,15 +729,54 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
190
729
  function mailRouting(invocationRoot) {
191
730
  const control = directory(invocationRoot);
192
731
  return {
193
- deliver: async (mail) => (await control.owner(mail.to.agentId, new AbortController().signal)).acceptMail(mail),
194
- readSettlement: async (agentId, sessionId, commandId) => (await control.owner(agentId, new AbortController().signal)).readSettlement(sessionId, commandId),
732
+ deliver: async (mail) => {
733
+ const remote = [...meshDirectories.values()].find(({ record }) => record.invocationRoot === invocationRoot &&
734
+ record.bindings.some((binding) => binding.agentId === mail.from.agentId));
735
+ if (remote !== undefined) {
736
+ if (!remote.agents.some((agent) => agent.agentId === mail.to.agentId))
737
+ throw new Error("Mesh mail recipient is outside this Team");
738
+ const result = await mesh.deliver(remote.record, {
739
+ key: mail.key,
740
+ kind: "agentMail",
741
+ address: {
742
+ scopeId: remote.record.scopeId,
743
+ agentId: mail.to.agentId,
744
+ },
745
+ event: mail,
746
+ });
747
+ if (result === "accepted" ||
748
+ result === "duplicate" ||
749
+ result === "refused" ||
750
+ !("receipt" in result) ||
751
+ result.status === "refused")
752
+ throw new Error("Mesh recipient did not return command acceptance");
753
+ return parseAccepted(JSON.stringify(result.receipt));
754
+ }
755
+ const { owner, release } = await control.owner(mail.to.agentId, new AbortController().signal);
756
+ try {
757
+ return await admitted(owner, () => owner.acceptMail(mail));
758
+ }
759
+ finally {
760
+ release();
761
+ }
762
+ },
763
+ // A settled command stays readable once its Agent is archived.
764
+ readSettlement: async (agentId, sessionId, commandId) => {
765
+ const { owner, release } = await control.owner(agentId, new AbortController().signal, true);
766
+ try {
767
+ return await owner.readSettlement(sessionId, commandId);
768
+ }
769
+ finally {
770
+ release();
771
+ }
772
+ },
195
773
  };
196
774
  }
197
775
  function directory(invocationRoot) {
198
- async function targets(signal) {
776
+ async function targets(signal, visibility = "active") {
199
777
  ensureRunning();
200
778
  signal.throwIfAborted();
201
- const paths = await listPrivateLocalAgentStores(invocationRoot);
779
+ const paths = await listPrivateLocalAgentStores(invocationRoot, visibility);
202
780
  signal.throwIfAborted();
203
781
  const result = new Map();
204
782
  for (const databasePath of paths) {
@@ -213,38 +791,187 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
213
791
  }
214
792
  return result;
215
793
  }
216
- async function admittedOwner(agentId, signal) {
217
- const databasePath = (await targets(signal)).get(agentId);
794
+ async function admittedOwner(agentId, signal, includeArchived = false) {
795
+ const visibility = includeArchived ? "all" : "active";
796
+ const databasePath = (await targets(signal, visibility)).get(agentId);
797
+ if (databasePath === undefined)
798
+ return outsideOrArchived(agentId, signal);
799
+ const { runtime, release } = await acquire({
800
+ invocationRoot,
801
+ databasePath,
802
+ });
803
+ try {
804
+ // Archived while the owner opened: the Agent is no longer in use.
805
+ if ((await targets(signal, visibility)).get(agentId) !== databasePath)
806
+ return await outsideOrArchived(agentId, signal);
807
+ signal.throwIfAborted();
808
+ ensureRunning();
809
+ return { owner: runtime, release };
810
+ }
811
+ catch (cause) {
812
+ release();
813
+ throw cause;
814
+ }
815
+ }
816
+ async function outsideOrArchived(agentId, signal) {
817
+ if ((await targets(signal, "archived")).has(agentId))
818
+ throw new AgentArchiveRefused("archived", `Agent ${agentId} is archived; unarchive it to work with it`);
819
+ throw new Error("Agent is outside this Local project");
820
+ }
821
+ /**
822
+ * The first command still queued or running on any of an Agent's Sessions,
823
+ * so an archive can name it rather than cancel it.
824
+ */
825
+ async function runningCommand(owner) {
826
+ let after;
827
+ do {
828
+ const page = await owner.listSessions(after);
829
+ for (const row of page.sessions) {
830
+ if (row.state !== "running" && row.state !== "needs_you")
831
+ continue;
832
+ const { activity } = await owner
833
+ .getSession(row.sessionId)
834
+ .read({ limit: 1 });
835
+ if (activity.queue.state !== "idle")
836
+ return {
837
+ sessionId: row.sessionId,
838
+ commandId: activity.queue.commandId,
839
+ };
840
+ }
841
+ after = page.next;
842
+ } while (after !== undefined);
843
+ return undefined;
844
+ }
845
+ /**
846
+ * Archive or restore one Agent by flipping the flag its store owns. A
847
+ * person may do it to any Agent; an Agent only to one it created, never
848
+ * itself. Archiving never cancels work, so it refuses while a command runs.
849
+ */
850
+ async function setArchived({ agentId, by }, archived, signal) {
851
+ const databasePath = (await targets(signal, "all")).get(agentId);
218
852
  if (databasePath === undefined)
219
853
  throw new Error("Agent is outside this Local project");
220
- const owner = await ownerFor({ invocationRoot, databasePath });
221
- if ((await targets(signal)).get(agentId) !== databasePath)
222
- throw new Error("Agent is outside this Local project");
223
- signal.throwIfAborted();
224
- ensureRunning();
225
- return owner;
854
+ let createdBy;
855
+ try {
856
+ ({ createdBy } = readPrivateLocalAgentProfile(databasePath));
857
+ }
858
+ catch (error) {
859
+ if (!(error instanceof LocalStoreSchemaError))
860
+ throw error;
861
+ throw new AgentArchiveRefused("unreadable", `Agent ${agentId}'s store ${unreadableStoreReason(agentId)}`);
862
+ }
863
+ if (by.kind === "agent") {
864
+ if (by.agentId === agentId)
865
+ throw new AgentArchiveRefused("self", "an Agent cannot archive or restore itself");
866
+ if (createdBy !== by.agentId)
867
+ throw new AgentArchiveRefused("not_creator", "an Agent can archive or restore only an Agent it created");
868
+ }
869
+ const { runtime: owner, release } = await acquire({
870
+ invocationRoot,
871
+ databasePath,
872
+ });
873
+ // Alone with the Agent: no work is admitted between the checks and the flag.
874
+ try {
875
+ await gateFor(databasePath).exclusive(async () => {
876
+ if (archived) {
877
+ const running = await runningCommand(owner);
878
+ if (running !== undefined)
879
+ throw new AgentArchiveRefused("running", `command ${running.commandId} is running in Session ${running.sessionId}; cancel it first`);
880
+ const waiting = await owner.pendingDelegation();
881
+ if (waiting !== undefined)
882
+ throw new AgentArchiveRefused("pending", waiting.unreadable === undefined
883
+ ? `it still waits for command ${waiting.commandId} it delegated to Agent ${waiting.agentId} (Session ${waiting.sessionId}); wait for it or cancel it first`
884
+ : `it delegated command ${waiting.commandId} to Agent ${waiting.agentId} (Session ${waiting.sessionId}) and that work cannot be read (${waiting.unreadable}); wait for it or cancel it first`);
885
+ }
886
+ signal.throwIfAborted();
887
+ owner.setArchived(archived);
888
+ });
889
+ }
890
+ finally {
891
+ release();
892
+ pruneGate(databasePath);
893
+ }
226
894
  }
227
895
  return {
228
896
  owner: admittedOwner,
229
- list: async (signal) => [...(await targets(signal)).keys()].map((agentId) => ({
230
- agentId,
231
- name: agentId,
232
- })),
233
- create: async ({ requestId, model }, signal) => {
897
+ archive: (request, signal) => setArchived(request, true, signal),
898
+ unarchive: (request, signal) => setArchived(request, false, signal),
899
+ list: async (signal, options) => {
900
+ const stores = await targets(signal, options?.archived === true ? "archived" : "active");
901
+ return [...stores].map(([agentId, databasePath]) => {
902
+ let profile;
903
+ try {
904
+ profile = readPrivateLocalAgentProfile(databasePath);
905
+ }
906
+ catch (error) {
907
+ // One store written before the current schema fails its own row,
908
+ // loudly; the other Agents still list.
909
+ if (!(error instanceof LocalStoreSchemaError))
910
+ throw error;
911
+ logEvent("list-agent-store", error);
912
+ return {
913
+ agentId,
914
+ name: agentId,
915
+ workingDirectory: invocationRoot,
916
+ unreadable: unreadableStoreReason(agentId),
917
+ };
918
+ }
919
+ const { name, model, createdBy } = profile;
920
+ let agent = {
921
+ agentId,
922
+ name: name ?? agentId,
923
+ workingDirectory: invocationRoot,
924
+ };
925
+ agent = { ...agent, createdBy };
926
+ return model === null ? agent : { ...agent, model };
927
+ });
928
+ },
929
+ create: async ({ requestId, name, model, extensions: requested = [], createdBy }, signal) => {
234
930
  ensureRunning();
235
931
  signal.throwIfAborted();
932
+ // An Agent creating an Agent cannot give it more than it holds. Refuse
933
+ // before anything exists; never trim the request.
934
+ const creatorDatabase = (await targets(signal)).get(createdBy);
935
+ if (creatorDatabase === undefined)
936
+ throw new AgentCreateRefused("no_creator", "the creating Agent is not in this Local project");
937
+ const creatorStore = dirname(creatorDatabase);
938
+ const sandbox = readPrivateLocalAgentProfile(creatorDatabase);
939
+ try {
940
+ await extensions.assertHeldByAgent(creatorStore, requested);
941
+ }
942
+ catch (error) {
943
+ if (error instanceof LocalExtensionError &&
944
+ error.reason === "not-held")
945
+ throw new AgentCreateRefused("exceeds_creator", error.message);
946
+ throw error;
947
+ }
236
948
  const key = `create:${JSON.stringify([invocationRoot, requestId])}`;
237
949
  const createStore = await resolvePrivateLocalCreateStore(invocationRoot, requestId);
238
950
  signal.throwIfAborted();
239
951
  ensureRunning();
240
952
  const databasePath = createStore.databasePath;
953
+ for (const id of requested)
954
+ await extensions.enableAgent(dirname(databasePath), id, {
955
+ agentStoreDirectory: creatorStore,
956
+ });
957
+ signal.throwIfAborted();
958
+ while (pendingReload() !== undefined)
959
+ await pendingReload();
960
+ ensureRunning();
961
+ await releasedClosed(databasePath);
962
+ ensureRunning();
241
963
  let pending = owners.get(key);
242
964
  if (pending === undefined) {
243
965
  pending = owners.get(`store:${databasePath}`);
244
966
  if (pending === undefined) {
245
967
  let options = {
968
+ configRoot,
246
969
  invocationRoot,
247
970
  createRequestId: requestId,
971
+ name,
972
+ createdBy,
973
+ requireSandbox: sandbox.requireSandbox,
974
+ folder: sandbox.folder,
248
975
  agentControl: directory(invocationRoot),
249
976
  mail: mailRouting(invocationRoot),
250
977
  };
@@ -255,6 +982,12 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
255
982
  modelId: model.modelId,
256
983
  reasoning: model.reasoning,
257
984
  };
985
+ const captured = configuration;
986
+ if (captured !== undefined)
987
+ options = {
988
+ ...options,
989
+ configuration: (store) => loadConfiguration(captured, invocationRoot, store),
990
+ };
258
991
  pending = trackOpening(openRuntime(options).then((runtime) => ({
259
992
  runtime,
260
993
  fungiMcp: undefined,
@@ -267,27 +1000,54 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
267
1000
  if (owner.runtime.databasePath !== databasePath)
268
1001
  throw new Error("Local Agent create identity does not match its store");
269
1002
  owners.set(key, pending);
270
- await owner.runtime.assertModelReady("main", signal);
1003
+ const release = hold(databasePath);
1004
+ try {
1005
+ await owner.runtime.assertModelReady("main", signal);
1006
+ }
1007
+ finally {
1008
+ release();
1009
+ }
271
1010
  const agentId = createStore.agentId;
272
- return Object.freeze({ agentId, name: agentId });
1011
+ const created = {
1012
+ agentId,
1013
+ name: owner.runtime.name,
1014
+ createdBy,
1015
+ workingDirectory: invocationRoot,
1016
+ };
1017
+ return Object.freeze(model === undefined ? created : { ...created, model });
273
1018
  },
274
1019
  setModel: async ({ agentId, model }, signal) => {
275
- const owner = await admittedOwner(agentId, signal);
276
- await owner.setModel(model, signal);
1020
+ const { owner, release } = await admittedOwner(agentId, signal);
1021
+ try {
1022
+ await owner.setModel(model, signal);
1023
+ }
1024
+ finally {
1025
+ release();
1026
+ }
277
1027
  },
278
1028
  checkModel: async ({ agentId, sessionId }, signal) => {
279
- const owner = await admittedOwner(agentId, signal);
280
- await owner.assertModelReady(sessionId, signal);
1029
+ const { owner, release } = await admittedOwner(agentId, signal);
1030
+ try {
1031
+ await owner.assertModelReady(sessionId, signal);
1032
+ }
1033
+ finally {
1034
+ release();
1035
+ }
281
1036
  },
282
1037
  open: async (agentId, signal) => {
283
- const owner = await admittedOwner(agentId, signal);
284
- // The service retains runtime ownership; an operation leases access only.
285
- const release = () => Promise.resolve();
1038
+ const held = await admittedOwner(agentId, signal);
1039
+ const owner = held.owner;
1040
+ // The service retains runtime ownership; an operation holds a use of it.
1041
+ const release = () => {
1042
+ held.release();
1043
+ return Promise.resolve();
1044
+ };
286
1045
  return {
287
1046
  agent: {
288
- session: owner.getSession,
1047
+ // Work starts only through an admission that finds the Agent in use.
1048
+ session: (sessionId) => gatedSession(owner, sessionId),
289
1049
  listSessions: owner.listSessions,
290
- createSession: owner.createSession,
1050
+ createSession: (requestId) => admitted(owner, () => owner.createSession(requestId)),
291
1051
  deleteSession: owner.deleteSession,
292
1052
  extensions: owner.extensions,
293
1053
  close: release,
@@ -299,21 +1059,68 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
299
1059
  }
300
1060
  function close() {
301
1061
  stopping = true;
1062
+ clearInterval(sweepTimer);
302
1063
  closing ??= (async () => {
1064
+ const meshResults = await Promise.allSettled([mesh.close()]);
1065
+ // End our observers before deciding which Agents have work to resume.
1066
+ // Their client disconnect may not have reached the server yet.
1067
+ await Promise.all([...eventResponses].map((stop) => stop()));
1068
+ // Restoration may still be reading references or opening one Agent.
1069
+ // It stops scheduling at shutdown; join it before saving or closing.
1070
+ await restoring;
1071
+ // Reload owns staged activation scopes until it publishes or discards.
1072
+ // Its caller receives validation failures; shutdown waits for cleanup.
1073
+ await Promise.allSettled(reloading === undefined ? [] : [reloading]);
303
1074
  await Promise.allSettled(pendingOpenings);
304
- const settled = await Promise.allSettled(opening);
1075
+ await Promise.allSettled([
1076
+ ...(sweeping === undefined ? [] : [sweeping]),
1077
+ ...releasing.values(),
1078
+ ]);
1079
+ // Close up to four independent stores concurrently within the listener's
1080
+ // single overall budget. One hung close must not block every other Agent.
1081
+ const settled = await Promise.allSettled([...opening.keys()].map(async (pending) => ({
1082
+ pending,
1083
+ owner: await pending,
1084
+ })));
305
1085
  const failures = [];
306
- for (const result of settled) {
307
- if (result.status === "fulfilled") {
308
- try {
309
- await result.value.runtime.close();
310
- }
311
- catch (cause) {
312
- logEvent("service-owner.close-runtime", cause);
313
- failures.push(cause);
314
- }
1086
+ for (const result of meshResults)
1087
+ if (result.status === "rejected")
1088
+ failures.push(result.reason);
1089
+ // The next service reopens only Agents with work to resume. Decide and
1090
+ // save that list before closing anything: if the listener's overall
1091
+ // drain budget ends a slow close, the saved list is already right.
1092
+ await Effect.runPromise(Effect.forEach(settled, (result) => Effect.promise(async () => {
1093
+ if (result.status !== "fulfilled")
1094
+ return;
1095
+ const { owner } = result.value;
1096
+ // A request still draining may yet commit work, so an Agent in
1097
+ // use or admitting work keeps its entry, before or after the read.
1098
+ const databasePath = owner.runtime.databasePath;
1099
+ const unused = () => !uses.has(databasePath) && !gates.has(databasePath);
1100
+ try {
1101
+ if (unused() && (await owner.runtime.idle()) && unused())
1102
+ recovery.delete(databasePath);
315
1103
  }
316
- }
1104
+ catch (cause) {
1105
+ logEvent("service-owner.read-idle", cause);
1106
+ }
1107
+ }), { concurrency: 4, discard: true }));
1108
+ await saveRecovery();
1109
+ await Effect.runPromise(Effect.forEach(settled, (result) => Effect.promise(async () => {
1110
+ if (result.status !== "fulfilled")
1111
+ return;
1112
+ const { pending, owner } = result.value;
1113
+ try {
1114
+ await owner.runtime.close();
1115
+ }
1116
+ catch (cause) {
1117
+ logEvent("service-owner.close-runtime", cause);
1118
+ failures.push(cause);
1119
+ }
1120
+ finally {
1121
+ opening.delete(pending);
1122
+ }
1123
+ }), { concurrency: 4, discard: true }));
317
1124
  owners.clear();
318
1125
  opening.clear();
319
1126
  pendingOpenings.clear();
@@ -322,24 +1129,37 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
322
1129
  })();
323
1130
  return closing;
324
1131
  }
325
- async function restore() {
1132
+ function restore() {
1133
+ restoring ??= restoreSaved();
1134
+ return restoring;
1135
+ }
1136
+ function loadRecovery() {
1137
+ loadingRecovery ??= readRecovery();
1138
+ return loadingRecovery;
1139
+ }
1140
+ function disableRecovery(reason, cause) {
1141
+ const error = new LocalRecoveryPersistenceDisabled(reason, cause);
1142
+ onRecoveryDisabled(reason === "read" ? "read-failed" : "decode-failed");
1143
+ logEvent(`service-owner.recovery-disabled.${error.reason}`, error);
1144
+ return { kind: "disabled", error };
1145
+ }
1146
+ async function readRecovery() {
326
1147
  if (recoveryPath === undefined)
327
- return;
1148
+ return { kind: "loaded", references: [] };
328
1149
  let content;
329
1150
  try {
330
- content = await readFile(recoveryPath, "utf8");
1151
+ content = await readRecoveryManifest(recoveryPath);
331
1152
  }
332
1153
  catch (cause) {
333
1154
  if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
334
- return;
335
- logEvent("service-owner.read-recovery", cause);
336
- return;
1155
+ return { kind: "loaded", references: [] };
1156
+ // Unknown contents are not an empty manifest. Keep the file untouched
1157
+ // for this service lifetime while still admitting on-demand Agents.
1158
+ return disableRecovery("read", cause);
337
1159
  }
338
1160
  const decoded = decodeRecoveryFile(content);
339
1161
  if (decoded._tag === "Failure") {
340
- logEvent("service-owner.decode-recovery", decoded.failure);
341
- await saveRecovery();
342
- return;
1162
+ return disableRecovery("decode", decoded.failure);
343
1163
  }
344
1164
  const saved = [];
345
1165
  for (const entry of decoded.success) {
@@ -349,15 +1169,29 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
349
1169
  continue;
350
1170
  }
351
1171
  saved.push(selection.data);
352
- recovery.set(selection.data.databasePath, selection.data);
1172
+ // A concurrent client may already have installed the current reference.
1173
+ if (!recovery.has(selection.data.databasePath))
1174
+ recovery.set(selection.data.databasePath, selection.data);
353
1175
  }
1176
+ return { kind: "loaded", references: saved };
1177
+ }
1178
+ async function restoreSaved() {
1179
+ const loaded = await loadRecovery();
1180
+ if (loaded.kind === "disabled")
1181
+ return;
354
1182
  // Preload valid references before opening, then isolate and prune each
355
1183
  // failed owner so an unavailable project cannot prevent service startup.
356
- for (const selection of saved) {
1184
+ for (const selection of loaded.references) {
1185
+ if (stopping)
1186
+ break;
357
1187
  try {
358
1188
  await ownerFor(selection);
359
1189
  }
360
1190
  catch (cause) {
1191
+ // A completed open remains owned and is drained by close(). Keep its
1192
+ // recovery reference, and all not-yet-opened references, for next time.
1193
+ if (closing !== undefined)
1194
+ break;
361
1195
  logEvent("service-owner.restore-entry", cause);
362
1196
  // A good owner may already have replaced another stale reference to
363
1197
  // this store. Prune only the failing reference, never that live owner.
@@ -367,5 +1201,169 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
367
1201
  }
368
1202
  await saveRecovery();
369
1203
  }
370
- return { createSession, select, restore, close };
1204
+ /** A person archives or restores any Agent of one project folder. */
1205
+ async function archive(request, signal) {
1206
+ const control = directory(await realpath(request.invocationRoot));
1207
+ const target = {
1208
+ agentId: request.agentId,
1209
+ by: { kind: "human" },
1210
+ };
1211
+ await (request.archived
1212
+ ? control.archive(target, signal)
1213
+ : control.unarchive(target, signal));
1214
+ }
1215
+ /**
1216
+ * This is Agent lifetime ancestry, not turn ancestry: a stopped parent still
1217
+ * owns its helpers. Snapshot once, visit descendants before their creator,
1218
+ * and never delete or archive their conversations.
1219
+ */
1220
+ async function stopAll(request) {
1221
+ ensureRunning();
1222
+ const invocationRoot = await realpath(request.invocationRoot);
1223
+ const paths = await listPrivateLocalAgentStores(invocationRoot, "all");
1224
+ if (paths.length > 4_096)
1225
+ throw new Error("Too many Agent stores to stop safely in one request; no cascade was started");
1226
+ const result = {
1227
+ rootAgentId: request.agentId,
1228
+ agents: [],
1229
+ discoveryFailures: [],
1230
+ };
1231
+ const agents = new Map();
1232
+ for (const databasePath of paths) {
1233
+ const agentId = basename(dirname(databasePath));
1234
+ try {
1235
+ if (!belongsToProject(invocationRoot, databasePath))
1236
+ throw new Error("Agent is outside this Local project; it was not stopped");
1237
+ const profile = readPrivateLocalAgentProfile(databasePath);
1238
+ agents.set(agentId, {
1239
+ databasePath,
1240
+ name: profile.name ?? agentId,
1241
+ createdBy: profile.createdBy,
1242
+ });
1243
+ }
1244
+ catch (cause) {
1245
+ result.discoveryFailures.push({
1246
+ agentId,
1247
+ reason: cause instanceof Error ? cause.message : String(cause),
1248
+ });
1249
+ }
1250
+ }
1251
+ if (!agents.has(request.agentId))
1252
+ throw new Error("The selected Agent could not be read; no cascade was started");
1253
+ // Iterative postorder is bounded by the discovered stores, including cycles.
1254
+ const seen = new Set();
1255
+ const stack = [{ agentId: request.agentId, visited: false }];
1256
+ const ordered = [];
1257
+ while (stack.length > 0) {
1258
+ const entry = stack.pop();
1259
+ if (entry === undefined)
1260
+ break;
1261
+ if (entry.visited) {
1262
+ ordered.push(entry.agentId);
1263
+ continue;
1264
+ }
1265
+ if (seen.has(entry.agentId))
1266
+ continue;
1267
+ seen.add(entry.agentId);
1268
+ stack.push({ ...entry, visited: true });
1269
+ const children = [...agents].filter(([, agent]) => agent.createdBy === entry.agentId);
1270
+ for (const [agentId] of children.reverse())
1271
+ stack.push({ agentId, visited: false });
1272
+ }
1273
+ for (const agentId of ordered) {
1274
+ const agent = agents.get(agentId);
1275
+ if (agent === undefined)
1276
+ continue;
1277
+ let timer;
1278
+ const deadline = new AbortController();
1279
+ try {
1280
+ const stopped = await Promise.race([
1281
+ (async () => {
1282
+ const { runtime: owner, release } = await acquire({
1283
+ invocationRoot,
1284
+ databasePath: agent.databasePath,
1285
+ });
1286
+ try {
1287
+ deadline.signal.throwIfAborted();
1288
+ let after;
1289
+ let stopped = false;
1290
+ do {
1291
+ const page = await owner.listSessions(after);
1292
+ deadline.signal.throwIfAborted();
1293
+ for (const row of page.sessions) {
1294
+ const session = owner.getSession(row.sessionId);
1295
+ const snapshot = await session.read({ limit: 1 });
1296
+ deadline.signal.throwIfAborted();
1297
+ if (snapshot.activity.queue.state !== "running" &&
1298
+ snapshot.pending.total === 0)
1299
+ continue;
1300
+ if (!snapshot.activity.cancellationRequested ||
1301
+ snapshot.pending.total > 0)
1302
+ await session.abort();
1303
+ // An accepted cancellation is not a completed stop. In particular,
1304
+ // first Esc may already have requested cancellation of this turn.
1305
+ // Keep the existing per-Agent deadline while awaiting durable idle.
1306
+ let settled = await session.read({ limit: 1 });
1307
+ while (settled.activity.queue.state === "running" ||
1308
+ settled.pending.total > 0) {
1309
+ await delay(20, undefined, { signal: deadline.signal });
1310
+ settled = await session.read({ limit: 1 });
1311
+ }
1312
+ deadline.signal.throwIfAborted();
1313
+ stopped = true;
1314
+ }
1315
+ after = page.next;
1316
+ } while (after !== undefined);
1317
+ return stopped;
1318
+ }
1319
+ finally {
1320
+ release();
1321
+ }
1322
+ })(),
1323
+ new Promise((_resolve, reject) => {
1324
+ timer = setTimeout(() => {
1325
+ const error = new Error("Agent did not answer within 5 seconds; stop is unconfirmed");
1326
+ deadline.abort(error);
1327
+ reject(error);
1328
+ }, 5_000);
1329
+ }),
1330
+ ]);
1331
+ result.agents.push({
1332
+ agentId,
1333
+ name: agent.name,
1334
+ outcome: stopped ? "stopped" : "idle",
1335
+ });
1336
+ }
1337
+ catch (cause) {
1338
+ result.agents.push({
1339
+ agentId,
1340
+ name: agent.name,
1341
+ outcome: "failed",
1342
+ reason: cause instanceof Error ? cause.message : String(cause),
1343
+ });
1344
+ }
1345
+ finally {
1346
+ clearTimeout(timer);
1347
+ }
1348
+ }
1349
+ return result;
1350
+ }
1351
+ return {
1352
+ refreshMesh: mesh.refresh,
1353
+ createSession,
1354
+ select,
1355
+ serve,
1356
+ effectiveModel,
1357
+ sandboxStatus,
1358
+ sweepIdle,
1359
+ archive,
1360
+ stopAll,
1361
+ restore,
1362
+ close,
1363
+ reload,
1364
+ unfinishedOwners: () => [
1365
+ ...(reloading === undefined ? [] : ["configuration reload"]),
1366
+ ...opening.values(),
1367
+ ],
1368
+ };
371
1369
  }