@fungi.computer/truffle 0.1.1 → 0.2.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.
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,77 +1,134 @@
1
1
  import { FungiMcpSelectionConflict } from "./private-fungi-mcp-selection.js";
2
- import { LocalSessionPathConflict } from "./private-local-service-owner.js";
3
- export { LocalSessionPathConflict } from "./private-local-service-owner.js";
2
+ import { logEvent } from "./log-event.js";
3
+ import { readBoundedResponse } from "./private-bounded-response.js";
4
+ import { LocalSandboxUnavailable, sandboxUnavailableReasonSchema, } from "./private-local-containment.js";
5
+ export { LocalSandboxUnavailable } from "./private-local-containment.js";
6
+ import { capturePrivateLocalConfigRoot } from "./private-local-config-root.js";
7
+ import { stopAllResultSchema } from "./private-local-stop-all.js";
8
+ import { LocalAgentSelectionAmbiguous, LocalSessionPathConflict, } from "./private-local-service-owner.js";
9
+ export { LocalAgentSelectionAmbiguous, LocalSessionPathConflict, } from "./private-local-service-owner.js";
4
10
  import { EventSource } from "eventsource";
11
+ import { AgentArchiveRefused } from "@fungi.computer/shiitake/agent-control";
12
+ export { AgentArchiveRefused } from "@fungi.computer/shiitake/agent-control";
5
13
  import { newWebSocketRpcSession } from "@fungi.computer/capnweb";
6
14
  import { runPrivateLocalAuthRemote, } from "./private-local-auth-rpc.js";
7
15
  import { spawn } from "node:child_process";
8
- import { stat } from "node:fs/promises";
9
- import { join } from "node:path";
10
16
  import { fileURLToPath } from "node:url";
11
17
  import { setTimeout as delay } from "node:timers/promises";
18
+ import { readInstalledLocalClientBuildId } from "./private-local-client-version.js";
19
+ export { readInstalledLocalClientBuildId } from "./private-local-client-version.js";
20
+ export { privateLocalServiceVersion } from "./private-local-service-discovery.js";
12
21
  import { createShiitakeClient, parseSessionSnapshot, } from "@fungi.computer/shiitake/client";
13
- import { acquireServiceLock, assertNoUntrackedServiceOwner, handshake, modelChoicesSchema, readLiveServiceStatus, readServiceRecord, removeOwnedServiceRecord, selectedSchema, sessionDiscoverySchema, sessionInspectionSchema, serviceDirectory, serviceOwnerIsLive, } from "./private-local-service-discovery.js";
22
+ import { acquireServiceLock, assertNoUntrackedServiceOwner, clearAbandonedServiceOwner, modelChoicesSchema, privateLocalServiceVersion, readLiveServiceStatus, readServiceRegistration, removeOwnedServiceRecord, removeUnidentifiedServiceRecord, selectedSchema, sandboxStatusSchema, sessionDiscoverySchema, archiveRefusalSchema, archiveRequestSchema, discoverRequestSchema, sessionInspectionSchema, serviceDirectory, serviceDirectoryPath, serviceOwnerIsLive, } from "./private-local-service-discovery.js";
23
+ import { terminatePrivateLocalService } from "./private-local-service-termination.js";
24
+ import { readServiceLeaseOwner } from "./private-local-service-lease.js";
14
25
  import { privateLocalEffectiveModelSchema } from "./private-local-effective-model.js";
15
- import { projectPrivateLocalSessionDiscovery } from "./private-local-session-discovery.js";
26
+ import { LocalStoreSchemaError, projectPrivateLocalSessionDiscovery, } from "./private-local-session-discovery.js";
27
+ export { LocalStoreSchemaError } from "./private-local-session-discovery.js";
16
28
  import { capturePrivateLocalExecPath, parsePrivateLocalExecPath, } from "./private-local-exec-path.js";
17
29
  import { z } from "zod";
30
+ import { serviceDiagnosticsStatusSchema } from "./private-local-service-log.js";
18
31
  export { runPrivateLocalWorkerServer, } from "./private-local-worker-server.js";
32
+ /** The 409 answers a select can give; each is one case of a closed union. */
33
+ const selectionConflictSchema = z.discriminatedUnion("code", [
34
+ z.strictObject({
35
+ code: z.literal("sandbox_unavailable"),
36
+ reason: sandboxUnavailableReasonSchema,
37
+ }),
38
+ z.object({ code: z.literal("session_path_conflict") }),
39
+ z.object({ code: z.literal("store_in_use") }),
40
+ z.object({
41
+ code: z.literal("store_schema_incompatible"),
42
+ databasePath: z.string().min(1),
43
+ error: z.string().min(1),
44
+ }),
45
+ z.object({
46
+ code: z.literal("agent_selection_ambiguous"),
47
+ agentIds: z.array(z.string()),
48
+ }),
49
+ ]);
50
+ /** Selection, creation and inspection share the listener's typed refusals. */
51
+ async function rejectSelectionConflict(response) {
52
+ const body = await response.json().catch((cause) => {
53
+ console.error("Local service response unavailable", cause);
54
+ return undefined;
55
+ });
56
+ const conflict = selectionConflictSchema.safeParse(body);
57
+ if (conflict.success) {
58
+ if (conflict.data.code === "store_schema_incompatible")
59
+ throw new LocalStoreSchemaError(conflict.data.databasePath);
60
+ if (conflict.data.code === "sandbox_unavailable")
61
+ throw new LocalSandboxUnavailable(conflict.data.reason);
62
+ if (conflict.data.code === "session_path_conflict")
63
+ throw new LocalSessionPathConflict();
64
+ if (conflict.data.code === "agent_selection_ambiguous")
65
+ throw new LocalAgentSelectionAmbiguous(conflict.data.agentIds);
66
+ throw new Error("This Agent store is held by another Fungi service. Stop the older service process, then retry; keep the Agent store files.");
67
+ }
68
+ // An Agent-selection answer without its Agents is a broken service, not
69
+ // an MCP connection conflict.
70
+ if (z.object({ code: z.literal("agent_selection_ambiguous") }).safeParse(body)
71
+ .success)
72
+ throw new Error("Local service sent an unreadable Agent selection");
73
+ throw new FungiMcpSelectionConflict();
74
+ }
75
+ /** The listener's body for a failure it did not expect. */
76
+ const serviceFailureSchema = z.object({
77
+ code: z.literal("internal_error"),
78
+ error: z.string(),
79
+ });
80
+ /** The service answered a selection with a failure status and no readable failure body. */
81
+ export class LocalServiceUnreadableResponse extends Error {
82
+ name = "LocalServiceUnreadableResponse";
83
+ status;
84
+ constructor(status, cause) {
85
+ super(`Local service selection failed (${String(status)}) without a readable failure body`, { cause });
86
+ this.status = status;
87
+ }
88
+ }
89
+ /**
90
+ * A newer client replaced the service this running client reached with one
91
+ * that speaks another protocol. Only restarting the window can reconnect it.
92
+ */
93
+ export class LocalServiceUpdated extends Error {
94
+ name = "LocalServiceUpdated";
95
+ constructor() {
96
+ super("The Fungi service was updated. Restart this window.");
97
+ }
98
+ }
19
99
  /** Construction does not start a service or open a store. Call start/connect explicitly. */
20
100
  export function createPrivateLocalServiceManager(options = {}) {
101
+ const root = capturePrivateLocalConfigRoot(options.home);
102
+ const selectedDirectory = serviceDirectoryPath(options.directory ?? root.serviceDirectory);
103
+ const reportCleanupFailure = options.reportCleanupFailure ??
104
+ ((cause) => {
105
+ process.stderr.write(`Local service cleanup failed (${cause instanceof Error ? cause.name : "NonError"}).\n`);
106
+ });
21
107
  const clientPath = options.executionPath === undefined
22
108
  ? capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd())
23
109
  : parsePrivateLocalExecPath(options.executionPath);
24
- const expectedBuildId = z
25
- .string()
26
- .regex(/^[a-f0-9]{64}$/u)
27
- .optional()
28
- .parse(process.env.FUNGI_BUNDLED_BUILD_ID);
29
- function acceptBuild(ready) {
30
- if (ready.buildId !== expectedBuildId)
31
- throw new Error("The running service belongs to a different Fungi build. Finish active turns, run fungi stop, then reopen Fungi. Saved Sessions are preserved.");
32
- return ready;
110
+ const expectedVersion = privateLocalServiceVersion();
111
+ /**
112
+ * Set once this client has reached a service. Only a starting client
113
+ * replaces a service speaking another protocol. Same-protocol build changes
114
+ * still require the installed-client check; reaching a service is not proof
115
+ * that this window remains the installed client.
116
+ */
117
+ let reachedService = false;
118
+ function accept(service) {
119
+ reachedService = true;
120
+ return service;
33
121
  }
34
122
  const maximumInspectionResponseBytes = 2_097_152;
35
- async function readBoundedResponse(response) {
36
- const declared = response.headers.get("content-length");
37
- if (declared !== null) {
38
- const declaredBytes = Number(declared);
39
- if (!Number.isSafeInteger(declaredBytes) ||
40
- declaredBytes < 0 ||
41
- declaredBytes > maximumInspectionResponseBytes)
42
- throw new Error("Local Session inspection response is too large");
43
- }
44
- if (response.body === null)
45
- throw new Error("Local Session inspection returned no body");
46
- const stream = response.body.pipeThrough(new TransformStream());
47
- const reader = stream.getReader();
48
- const chunks = [];
49
- let size = 0;
50
- for (;;) {
51
- const next = await reader.read();
52
- if (next.done)
53
- break;
54
- const bytes = next.value;
55
- size += bytes.byteLength;
56
- if (size > maximumInspectionResponseBytes) {
57
- await reader.cancel();
58
- throw new Error("Local Session inspection response is too large");
59
- }
60
- chunks.push(bytes);
61
- }
62
- const joined = new Uint8Array(size);
63
- let offset = 0;
64
- for (const chunk of chunks) {
65
- joined.set(chunk, offset);
66
- offset += chunk.byteLength;
67
- }
68
- return new TextDecoder().decode(joined);
69
- }
70
123
  async function status(timeoutMs) {
71
- const record = await readServiceRecord(await serviceDirectory(options.directory));
72
- if (record === undefined)
124
+ const directory = selectedDirectory;
125
+ const registration = await readServiceRegistration(directory);
126
+ if (registration?.kind === "other-protocol" &&
127
+ (await serviceOwnerIsLive(directory)))
128
+ throw new LocalServiceUpdated();
129
+ if (registration?.kind !== "current")
73
130
  return undefined;
74
- const probe = await readLiveServiceStatus(record, timeoutMs);
131
+ const probe = await readLiveServiceStatus(registration.record, timeoutMs);
75
132
  return probe.kind === "running" ? probe.service : undefined;
76
133
  }
77
134
  /**
@@ -85,38 +142,94 @@ export function createPrivateLocalServiceManager(options = {}) {
85
142
  while (Date.now() < deadline) {
86
143
  request.signal?.throwIfAborted();
87
144
  const ready = await status(Math.min(10_000, waitMs));
88
- if (ready)
89
- return acceptBuild(ready);
145
+ if (ready) {
146
+ if (ready.version === expectedVersion)
147
+ return accept(ready);
148
+ // It answered at last, as another version: replace it under the lock.
149
+ const started = await startUnderLock(request);
150
+ if (started !== "busy")
151
+ return started;
152
+ }
90
153
  await delay(250, undefined, { signal: request.signal });
91
154
  }
92
155
  throw new Error(`The Fungi service is running but has not answered for ${String(Math.round(waitMs / 1000))} seconds; the machine may be overloaded. Retry, or run fungi stop after its turns finish.`);
93
156
  }
94
- async function ownerLockReleased(directory) {
95
- try {
96
- await stat(join(directory, "owner.lock"));
97
- return false;
98
- }
99
- catch (cause) {
100
- if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
101
- return true;
102
- throw cause;
103
- }
104
- }
105
157
  async function start(request = {}) {
106
158
  const started = await startUnderLock(request);
107
159
  return started === "busy" ? awaitBusyService(request) : started;
108
160
  }
161
+ /** `fungi stop`'s work, under the startup lock that serializes it with starts. */
162
+ async function stopUnderLock(directory, expectedServiceId) {
163
+ const registration = await readServiceRegistration(directory);
164
+ if (registration === undefined)
165
+ return { kind: "not-running", recordCleaned: false };
166
+ if (registration.kind === "unidentified") {
167
+ if (await serviceOwnerIsLive(directory))
168
+ throw new Error(`A Fungi service this client cannot identify owns ${directory}. End that Fungi service process, then retry; saved Sessions are kept.`);
169
+ await removeUnidentifiedServiceRecord(directory);
170
+ return { kind: "not-running", recordCleaned: true };
171
+ }
172
+ const lifecycle = registration.kind === "current"
173
+ ? registration.record
174
+ : registration.lifecycle;
175
+ if (expectedServiceId !== undefined &&
176
+ lifecycle.serviceId !== expectedServiceId)
177
+ return { kind: "replaced" };
178
+ const owner = await readServiceLeaseOwner(directory);
179
+ if (owner === undefined) {
180
+ await removeOwnedServiceRecord(directory, lifecycle.serviceId);
181
+ return { kind: "not-running", recordCleaned: true };
182
+ }
183
+ // The file points at a claim; it cannot supply signal authority itself.
184
+ if (owner.pid !== lifecycle.pid || owner.startedAt !== lifecycle.startedAt)
185
+ throw new Error(`The Fungi lease owner in ${directory} does not match service.json; retry after the owner registers itself. No process was signalled.`);
186
+ const outcome = await terminatePrivateLocalService(directory, owner);
187
+ // A killed process cannot remove its own record or lifetime lock.
188
+ await removeOwnedServiceRecord(directory, lifecycle.serviceId);
189
+ await clearAbandonedServiceOwner(directory);
190
+ return { kind: outcome };
191
+ }
192
+ /**
193
+ * Reuse the registered service when this client can, otherwise stop it so a
194
+ * service of this version can start; undefined means start one.
195
+ */
196
+ async function reuseOrReplace(directory, registration) {
197
+ if (registration.kind === "current") {
198
+ const probe = await readLiveServiceStatus(registration.record);
199
+ // Not answering: the lifetime lease decides between busy and gone.
200
+ if (probe.kind !== "running")
201
+ return undefined;
202
+ if (probe.service.version === expectedVersion)
203
+ return probe.service;
204
+ // Replace only for the installed client. A stale window (or an
205
+ // uninstalled source run) must not stop the service; it restarts.
206
+ if (expectedVersion !== (await readInstalledLocalClientBuildId()))
207
+ throw new LocalServiceUpdated();
208
+ }
209
+ else if (registration.kind === "other-protocol" && reachedService) {
210
+ if (await serviceOwnerIsLive(directory))
211
+ throw new LocalServiceUpdated();
212
+ }
213
+ if (registration.kind === "other-protocol" &&
214
+ expectedVersion !== (await readInstalledLocalClientBuildId()))
215
+ throw new LocalServiceUpdated();
216
+ await stopUnderLock(directory);
217
+ return undefined;
218
+ }
109
219
  async function startUnderLock(request) {
110
220
  request.signal?.throwIfAborted();
111
- const directory = await serviceDirectory(options.directory);
221
+ const directory = await serviceDirectory(selectedDirectory);
112
222
  const release = await acquireServiceLock(directory, "startup");
113
223
  try {
114
- const existing = await status();
224
+ const registration = await readServiceRegistration(directory);
225
+ const existing = registration === undefined
226
+ ? undefined
227
+ : await reuseOrReplace(directory, registration);
115
228
  request.signal?.throwIfAborted();
116
229
  if (existing)
117
- return acceptBuild(existing);
230
+ return accept(existing);
118
231
  await assertNoUntrackedServiceOwner(directory);
119
- if (serviceOwnerIsLive(directory))
232
+ if (await serviceOwnerIsLive(directory))
120
233
  return "busy";
121
234
  const bundledCli = process.env.FUNGI_BUNDLED_CLI;
122
235
  const child = spawn(options.executable ?? process.execPath, bundledCli === undefined
@@ -124,7 +237,13 @@ export function createPrivateLocalServiceManager(options = {}) {
124
237
  fileURLToPath(new URL("./private-local-service-process.js", import.meta.url)),
125
238
  directory,
126
239
  ]
127
- : [bundledCli, "--internal-service", directory], { detached: true, stdio: "ignore" });
240
+ : [bundledCli, "--internal-service", directory], {
241
+ detached: true,
242
+ stdio: "ignore",
243
+ // Service startup retains its existing inherited inputs. This is
244
+ // not a Computer exec child: only its selected host HOME is pinned.
245
+ env: { ...process.env, HOME: root.home },
246
+ });
128
247
  let failure;
129
248
  let exited = false;
130
249
  const readFailure = () => failure;
@@ -141,8 +260,11 @@ export function createPrivateLocalServiceManager(options = {}) {
141
260
  if (childFailure !== undefined)
142
261
  throw childFailure;
143
262
  const ready = await status();
144
- if (ready)
145
- return acceptBuild(ready);
263
+ if (ready) {
264
+ if (ready.version !== expectedVersion)
265
+ throw new Error(`The started Local service reports version ${ready.version}, not this client's ${expectedVersion}`);
266
+ return accept(ready);
267
+ }
146
268
  if (hasExited())
147
269
  throw new Error("Local service exited before its handshake");
148
270
  await delay(100, undefined, { signal: request.signal });
@@ -158,59 +280,183 @@ export function createPrivateLocalServiceManager(options = {}) {
158
280
  await release();
159
281
  }
160
282
  }
161
- const openConnection = async (endpoint, input, request = {}) => {
162
- request.signal?.throwIfAborted();
163
- const service = await start(request);
283
+ /** POST one selection to `service`; every refusal is a typed failure. */
284
+ const postSelection = async (endpoint, input, service, signal) => {
164
285
  const init = {
165
286
  method: "POST",
166
287
  headers: { "Content-Type": "application/json" },
167
288
  body: JSON.stringify({ ...input, clientPath }),
168
289
  redirect: "error",
169
290
  };
170
- if (request.signal !== undefined)
171
- init.signal = request.signal;
291
+ if (signal !== undefined)
292
+ init.signal = signal;
172
293
  const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/${endpoint}`, init);
173
- if (response.status === 409) {
174
- const conflict = z.object({ code: z.string() }).safeParse(await response.json().catch((cause) => {
175
- console.error("Local service response unavailable", cause);
176
- return undefined;
294
+ if (response.status === 409)
295
+ await rejectSelectionConflict(response);
296
+ if (!response.ok) {
297
+ // The service names its own failure in a JSON body; one without it is
298
+ // a broken response, not a status to guess from.
299
+ const failure = serviceFailureSchema.safeParse(await response.json().catch((cause) => {
300
+ throw new LocalServiceUnreadableResponse(response.status, cause);
177
301
  }));
178
- if (conflict.success && conflict.data.code === "session_path_conflict")
179
- throw new LocalSessionPathConflict();
180
- if (conflict.success && conflict.data.code === "store_in_use")
181
- throw new Error("This Agent store is held by another Fungi service. Stop the older service process, then retry; keep the Agent store files.");
182
- throw new FungiMcpSelectionConflict();
302
+ if (!failure.success)
303
+ throw new LocalServiceUnreadableResponse(response.status, failure.error);
304
+ throw new Error(`Local service selection failed (${String(response.status)}): ${failure.data.error}`);
305
+ }
306
+ return selectedSchema.parse(await response.json());
307
+ };
308
+ const routeBase = (service, selected) => `${service.baseUrl}/service/${service.serviceId}${selected.routePath}`;
309
+ const openConnection = async (endpoint, input, request = {}) => {
310
+ request.signal?.throwIfAborted();
311
+ const service = await start(request);
312
+ const selected = await postSelection(endpoint, input, service, request.signal);
313
+ const firstBase = routeBase(service, selected);
314
+ /**
315
+ * Where this connection's client currently talks. A restarted service
316
+ * comes back on a new port with a new id, so the client's requests are
317
+ * redirected here rather than the client rebuilt: its Session address
318
+ * and everything the TUI keyed on it stay the same.
319
+ */
320
+ let route = { service, base: firstBase };
321
+ let closing;
322
+ const isClosing = () => closing !== undefined;
323
+ const reroute = (url) => url === firstBase || url.startsWith(`${firstBase}/`)
324
+ ? route.base + url.slice(firstBase.length)
325
+ : url;
326
+ const transport = globalThis.fetch;
327
+ const releaseRoute = async (base) => {
328
+ try {
329
+ const response = await transport(`${base}/`, {
330
+ method: "DELETE",
331
+ redirect: "error",
332
+ signal: AbortSignal.timeout(5_000),
333
+ });
334
+ if (!response.ok && response.status !== 404)
335
+ throw new Error(`Local client release failed (${String(response.status)})`);
336
+ }
337
+ catch (error) {
338
+ if (options.reportCleanupFailure === undefined)
339
+ logEvent("service-client.release", error);
340
+ else
341
+ options.reportCleanupFailure(error);
342
+ }
343
+ };
344
+ const reroutedFetch = Object.assign((target, init) => {
345
+ if (target instanceof Request)
346
+ throw new TypeError("A Local service connection fetches by URL");
347
+ return transport(reroute(String(target)), init);
348
+ }, { preconnect: transport.preconnect });
349
+ const Source = options.eventSource ?? EventSource;
350
+ class ReroutedEventSource extends Source {
351
+ constructor(url) {
352
+ super(reroute(url));
353
+ // The library would retry the address it was built with, forever and
354
+ // silently. A dropped stream is surfaced instead, so the owner of the
355
+ // connection can rebind it and reopen the observation.
356
+ this.addEventListener("error", () => {
357
+ this.close();
358
+ });
359
+ }
183
360
  }
184
- if (!response.ok)
185
- throw new Error(`Local service selection failed (${String(response.status)})`);
186
- const selected = selectedSchema.parse(await response.json());
187
- const baseUrl = `${service.baseUrl}/service/${service.serviceId}${selected.routePath}`;
188
361
  const client = createShiitakeClient({
189
- baseUrl,
190
- eventSource: options.eventSource ?? EventSource,
362
+ baseUrl: firstBase,
363
+ fetch: reroutedFetch,
364
+ eventSource: ReroutedEventSource,
191
365
  });
192
366
  return {
193
- service,
367
+ get service() {
368
+ return route.service;
369
+ },
194
370
  agentId: selected.agentId,
195
371
  databasePath: selected.databasePath,
196
372
  sessionId: selected.sessionId,
197
373
  client,
198
374
  session: client.session(selected.sessionId),
375
+ sandboxStatus: async (request = {}) => {
376
+ request.signal?.throwIfAborted();
377
+ const init = { redirect: "error" };
378
+ if (request.signal !== undefined)
379
+ init.signal = request.signal;
380
+ const response = await transport(`${route.base}/sandbox`, init);
381
+ if (!response.ok)
382
+ throw new Error(`Local sandbox status read failed (${String(response.status)})`);
383
+ return sandboxStatusSchema.parse(await response.json());
384
+ },
385
+ stopAll: async () => {
386
+ const response = await fetch(`${route.service.baseUrl}/service/${route.service.serviceId}/stop-all`, {
387
+ method: "POST",
388
+ headers: { "Content-Type": "application/json" },
389
+ body: JSON.stringify({
390
+ invocationRoot: input.invocationRoot,
391
+ agentId: selected.agentId,
392
+ }),
393
+ redirect: "error",
394
+ });
395
+ if (!response.ok)
396
+ throw new Error(`Stop all failed (${String(response.status)})`);
397
+ return stopAllResultSchema.parse(await response.json());
398
+ },
199
399
  effectiveModel: async (request = {}) => {
200
400
  request.signal?.throwIfAborted();
201
401
  const init = { redirect: "error" };
202
402
  if (request.signal !== undefined)
203
403
  init.signal = request.signal;
204
- const response = await fetch(`${baseUrl}/sessions/${encodeURIComponent(selected.sessionId)}/effective-model`, init);
404
+ const response = await transport(`${route.base}/sessions/${encodeURIComponent(selected.sessionId)}/effective-model`, init);
205
405
  if (!response.ok)
206
406
  throw new Error(`Local effective model read failed (${String(response.status)})`);
207
407
  return privateLocalEffectiveModelSchema.parse(await response.json());
208
408
  },
209
- close: () => client.close(),
409
+ rebind: async (rebindRequest = {}) => {
410
+ if (isClosing())
411
+ throw new Error("Local service connection is closed");
412
+ rebindRequest.signal?.throwIfAborted();
413
+ // A healthy probe does not guarantee that selection will answer.
414
+ // Bound each attempt independently of the pane's lifetime.
415
+ const deadline = AbortSignal.timeout(5_000);
416
+ const signal = rebindRequest.signal === undefined
417
+ ? deadline
418
+ : AbortSignal.any([rebindRequest.signal, deadline]);
419
+ // Read service.json again, and never start a service: whoever
420
+ // stopped it decides when it returns.
421
+ const ready = await status();
422
+ if (ready === undefined)
423
+ return { kind: "unavailable" };
424
+ accept(ready);
425
+ const again = await postSelection("select", {
426
+ invocationRoot: input.invocationRoot,
427
+ databasePath: selected.databasePath,
428
+ sessionId: selected.sessionId,
429
+ fungiMcp: input.fungiMcp,
430
+ uiAttachmentLifetime: input.uiAttachmentLifetime,
431
+ }, ready, signal);
432
+ if (again.agentId !== selected.agentId ||
433
+ again.sessionId !== selected.sessionId)
434
+ throw new Error("The restarted Local service selected a different Session");
435
+ const nextBase = routeBase(ready, again);
436
+ if (isClosing()) {
437
+ await releaseRoute(nextBase);
438
+ throw new Error("Local service connection is closed");
439
+ }
440
+ const previous = route;
441
+ route = { service: ready, base: nextBase };
442
+ if (previous.service.serviceId === ready.serviceId)
443
+ await releaseRoute(previous.base);
444
+ return { kind: "rebound", service: ready };
445
+ },
446
+ close: () => {
447
+ closing ??= (async () => {
448
+ const base = route.base;
449
+ // Stop local observations first, including when the service is gone.
450
+ await client.close();
451
+ await releaseRoute(base);
452
+ })();
453
+ return closing;
454
+ },
210
455
  };
211
456
  };
212
457
  const connect = (selection, request) => openConnection("select", selection, request);
213
458
  const inspectResponseSchema = z.strictObject({
459
+ diagnostics: serviceDiagnosticsStatusSchema,
214
460
  selected: selectedSchema,
215
461
  snapshot: z.unknown(),
216
462
  });
@@ -225,7 +471,10 @@ export function createPrivateLocalServiceManager(options = {}) {
225
471
  const init = {
226
472
  method: "POST",
227
473
  headers: { "Content-Type": "application/json" },
228
- body: JSON.stringify({ invocationRoot }),
474
+ body: JSON.stringify(discoverRequestSchema.parse({
475
+ invocationRoot,
476
+ archived: request.archived,
477
+ })),
229
478
  redirect: "error",
230
479
  };
231
480
  if (request.signal !== undefined)
@@ -235,6 +484,32 @@ export function createPrivateLocalServiceManager(options = {}) {
235
484
  throw new Error(`Local session discovery failed (${String(response.status)})`);
236
485
  return projectPrivateLocalSessionDiscovery(sessionDiscoverySchema.parse(await response.json()), connect);
237
486
  },
487
+ async archive(archival, request = {}) {
488
+ request.signal?.throwIfAborted();
489
+ const service = await start(request);
490
+ const init = {
491
+ method: "POST",
492
+ headers: { "Content-Type": "application/json" },
493
+ body: JSON.stringify(archiveRequestSchema.parse(archival)),
494
+ redirect: "error",
495
+ };
496
+ if (request.signal !== undefined)
497
+ init.signal = request.signal;
498
+ const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/archive`, init);
499
+ if (response.status === 409) {
500
+ const refusal = archiveRefusalSchema.safeParse(await response.json().catch((cause) => {
501
+ throw new LocalServiceUnreadableResponse(response.status, cause);
502
+ }));
503
+ if (refusal.success)
504
+ throw new AgentArchiveRefused(refusal.data.reason, refusal.data.error);
505
+ }
506
+ if (!response.ok) {
507
+ const failure = serviceFailureSchema.safeParse(await response.json().catch((cause) => {
508
+ throw new LocalServiceUnreadableResponse(response.status, cause);
509
+ }));
510
+ throw new Error(`Local Agent archive failed (${String(response.status)})${failure.success ? `: ${failure.data.error}` : ""}`);
511
+ }
512
+ },
238
513
  async inspect(selection, request = {}) {
239
514
  request.signal?.throwIfAborted();
240
515
  const service = await start(request);
@@ -251,15 +526,18 @@ export function createPrivateLocalServiceManager(options = {}) {
251
526
  if (request.signal !== undefined)
252
527
  init.signal = request.signal;
253
528
  const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/inspect`, init);
529
+ if (response.status === 409)
530
+ await rejectSelectionConflict(response);
254
531
  if (!response.ok)
255
532
  throw new Error(`Local Session inspection failed (${String(response.status)})`);
256
- const decoded = inspectResponseSchema.parse(JSON.parse(await readBoundedResponse(response)));
533
+ const decoded = inspectResponseSchema.parse(JSON.parse(await readBoundedResponse(response, maximumInspectionResponseBytes, "Local Session inspection")));
257
534
  const baseUrl = `${service.baseUrl}/service/${service.serviceId}${decoded.selected.routePath}`;
258
535
  const client = createShiitakeClient({
259
536
  baseUrl,
260
537
  eventSource: options.eventSource ?? EventSource,
261
538
  });
262
539
  return {
540
+ diagnostics: decoded.diagnostics,
263
541
  session: client.session(decoded.selected.sessionId),
264
542
  snapshot: parseSessionSnapshot(JSON.stringify(decoded.snapshot)),
265
543
  close: () => client.close(),
@@ -274,7 +552,7 @@ export function createPrivateLocalServiceManager(options = {}) {
274
552
  const socket = new WebSocket(url);
275
553
  const remote = newWebSocketRpcSession(socket);
276
554
  try {
277
- return await runPrivateLocalAuthRemote(remote, request, interaction);
555
+ return await runPrivateLocalAuthRemote(remote, request, interaction, reportCleanupFailure);
278
556
  }
279
557
  finally {
280
558
  socket.close();
@@ -291,36 +569,34 @@ export function createPrivateLocalServiceManager(options = {}) {
291
569
  throw new Error(`Local service model catalogue failed (${String(response.status)})`);
292
570
  return modelChoicesSchema.parse(await response.json());
293
571
  },
572
+ async reload(invocationRoot) {
573
+ const service = await status();
574
+ if (service === undefined)
575
+ throw new Error("Local service is not running");
576
+ const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/reload`, {
577
+ method: "POST",
578
+ headers: { "Content-Type": "application/json" },
579
+ body: JSON.stringify({ invocationRoot, clientPath }),
580
+ redirect: "error",
581
+ signal: AbortSignal.timeout(30_000),
582
+ });
583
+ const result = z
584
+ .union([
585
+ z.strictObject({ message: z.string() }),
586
+ z.strictObject({ error: z.string() }),
587
+ ])
588
+ .parse(await response.json());
589
+ if ("error" in result)
590
+ throw new Error(result.error);
591
+ if (!response.ok)
592
+ throw new Error(`Configuration reload failed (${String(response.status)})`);
593
+ return result.message;
594
+ },
294
595
  async stop(expectedServiceId) {
295
- const directory = await serviceDirectory(options.directory);
596
+ const directory = await serviceDirectory(selectedDirectory);
296
597
  const release = await acquireServiceLock(directory, "startup");
297
598
  try {
298
- const service = await readServiceRecord(directory);
299
- if (service === undefined)
300
- return { kind: "not-running", recordCleaned: false };
301
- if (expectedServiceId !== undefined &&
302
- service.serviceId !== expectedServiceId)
303
- return { kind: "replaced" };
304
- const probe = await readLiveServiceStatus(service);
305
- if (probe.kind === "not-running" && !serviceOwnerIsLive(directory)) {
306
- await removeOwnedServiceRecord(directory, service.serviceId);
307
- return { kind: "not-running", recordCleaned: true };
308
- }
309
- if (probe.kind !== "running")
310
- throw new Error("Local service is not answering; retry after its active turns finish.");
311
- const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/stop`, {
312
- method: "POST",
313
- redirect: "error",
314
- });
315
- if (!response.ok)
316
- throw new Error("Local service refused shutdown");
317
- for (let attempt = 0; attempt < 100; attempt += 1) {
318
- if (!(await handshake(service)) &&
319
- (await ownerLockReleased(directory)))
320
- return { kind: "stopped" };
321
- await delay(100);
322
- }
323
- throw new Error("Local service shutdown has not released its owner lock");
599
+ return await stopUnderLock(directory, expectedServiceId);
324
600
  }
325
601
  finally {
326
602
  await release();