@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
@@ -21554,19 +21554,9 @@ function parseCatalog(input) {
21554
21554
  }
21555
21555
  }
21556
21556
  const preparedOperations = operations.map((operation) => prepareOperation(operation));
21557
- const namespaces = /* @__PURE__ */ new Map();
21558
21557
  for (const operation of preparedOperations) {
21559
- const namespace = operation.path.slice(0, operation.path.lastIndexOf("."));
21560
- const group = namespaces.get(namespace) ?? [];
21561
- group.push(operation);
21562
- namespaces.set(namespace, group);
21563
- }
21564
- for (const [namespace, group] of namespaces) {
21565
- const rendered = group.map((operation) => operation.rendered);
21566
- const index = renderNamespaceIndex(rendered);
21567
- const full = renderOperationTypes(rendered);
21568
- if (index.length > maximumDescriptionTypes || full.length > maximumDescriptionTypes) {
21569
- throw new ForageError("invalid_input", `Namespace description exceeds the bounded output: ${namespace}`);
21558
+ if (renderOperationTypes([operation.rendered]).length > maximumDescriptionTypes) {
21559
+ throw new ForageError("invalid_input", `Operation description exceeds the bounded output: ${operation.path}`);
21570
21560
  }
21571
21561
  }
21572
21562
  return {
@@ -27027,6 +27017,10 @@ var GuestThrew = class extends TaggedError2("GuestThrew") {
27027
27017
  };
27028
27018
  var DeadlineExceeded = class extends TaggedError2("DeadlineExceeded") {
27029
27019
  };
27020
+ var SandboxBusy = class extends TaggedError2("SandboxBusy") {
27021
+ /** Stable contention code, not a sandbox health failure. */
27022
+ code = "sandbox-busy";
27023
+ };
27030
27024
  var Cancelled = class extends TaggedError2("Cancelled") {
27031
27025
  };
27032
27026
  var DispatchLost = class extends TaggedError2("DispatchLost") {
@@ -27064,6 +27058,9 @@ function sandboxFailureMessage(failure, includeReference = true) {
27064
27058
  case "DeadlineExceeded":
27065
27059
  text = `execute hit its ${String(failure.limitMs)} ms deadline after ${String(failure.elapsedMs)} ms; split the work or persist progress${failure.acknowledged === false ? "; sandbox did not acknowledge cancellation" : ""}`;
27066
27060
  break;
27061
+ case "SandboxBusy":
27062
+ text = `sandbox-busy: all ${String(failure.capacity)} sandbox slots remained occupied after waiting ${String(failure.waitedMs)} ms. Nothing ran; retry this call after capacity becomes available.`;
27063
+ break;
27067
27064
  case "Cancelled":
27068
27065
  text = failure.acknowledged === false ? "Sandbox did not acknowledge cancellation" : "Execution was cancelled";
27069
27066
  break;
@@ -27096,6 +27093,7 @@ var ownedFailureSchema = external_exports.lazy(() => external_exports.union([
27096
27093
  external_exports.instanceof(SourceInvalid),
27097
27094
  external_exports.instanceof(GuestThrew),
27098
27095
  external_exports.instanceof(DeadlineExceeded),
27096
+ external_exports.instanceof(SandboxBusy),
27099
27097
  external_exports.instanceof(Cancelled),
27100
27098
  external_exports.instanceof(DispatchLost),
27101
27099
  external_exports.instanceof(SandboxCrashed),
@@ -27207,6 +27205,7 @@ var transportEnvelopeSchema = external_exports.lazy(() => external_exports.stric
27207
27205
  "SourceInvalid",
27208
27206
  "GuestThrew",
27209
27207
  "DeadlineExceeded",
27208
+ "SandboxBusy",
27210
27209
  "Cancelled",
27211
27210
  "DispatchLost",
27212
27211
  "SandboxCrashed",
@@ -2066,7 +2066,7 @@
2066
2066
  "format": "esm"
2067
2067
  },
2068
2068
  "../../packages/forage/dist/catalog.js": {
2069
- "bytes": 5055,
2069
+ "bytes": 4793,
2070
2070
  "imports": [
2071
2071
  {
2072
2072
  "path": "../../packages/forage/dist/errors.js",
@@ -6156,7 +6156,7 @@
6156
6156
  "format": "esm"
6157
6157
  },
6158
6158
  "../../packages/mycelium/dist/sandbox-failure.js": {
6159
- "bytes": 6983,
6159
+ "bytes": 7648,
6160
6160
  "imports": [
6161
6161
  {
6162
6162
  "path": "../../node_modules/.pnpm/effect@4.0.2/node_modules/effect/dist/Data.js",
@@ -6167,7 +6167,7 @@
6167
6167
  "format": "esm"
6168
6168
  },
6169
6169
  "../../packages/mycelium/dist/sandbox-boundary.js": {
6170
- "bytes": 13321,
6170
+ "bytes": 13388,
6171
6171
  "imports": [
6172
6172
  {
6173
6173
  "path": "../../packages/cairn/dist/index.js",
@@ -6558,7 +6558,7 @@
6558
6558
  "bytesInOutput": 1793
6559
6559
  },
6560
6560
  "../../packages/forage/dist/catalog.js": {
6561
- "bytesInOutput": 4139
6561
+ "bytesInOutput": 3677
6562
6562
  },
6563
6563
  "../../packages/forage/dist/app-api.js": {
6564
6564
  "bytesInOutput": 1649
@@ -6708,10 +6708,10 @@
6708
6708
  "bytesInOutput": 9993
6709
6709
  },
6710
6710
  "../../packages/mycelium/dist/sandbox-failure.js": {
6711
- "bytesInOutput": 3277
6711
+ "bytesInOutput": 3670
6712
6712
  },
6713
6713
  "../../packages/mycelium/dist/sandbox-boundary.js": {
6714
- "bytesInOutput": 6369
6714
+ "bytesInOutput": 6432
6715
6715
  },
6716
6716
  "../../packages/mycelium/dist/sandbox-guest.js": {
6717
6717
  "bytesInOutput": 4954
@@ -6720,7 +6720,7 @@
6720
6720
  "bytesInOutput": 4648
6721
6721
  }
6722
6722
  },
6723
- "bytes": 993382
6723
+ "bytes": 993376
6724
6724
  }
6725
6725
  }
6726
6726
  }
@@ -0,0 +1,12 @@
1
+ import type { ExtensionGrantV0, RosteredExtensionV0 } from "@fungi.computer/shiitake/extensions";
2
+ /** What the Local roster gains for the compaction archive. */
3
+ export type LocalArchiveHandoff = Readonly<{
4
+ roster: readonly RosteredExtensionV0[];
5
+ grants: readonly ExtensionGrantV0[];
6
+ }>;
7
+ /**
8
+ * Zookeeper's compaction handoff over files beside the Agent store. An
9
+ * installed extension the owner chose keeps the single `compaction_handoff`
10
+ * slot: Zookeeper is then skipped and the skip is reported, never a failure.
11
+ */
12
+ export declare function localArchiveHandoff(storeDirectory: string, installedGrants: readonly ExtensionGrantV0[], report?: (operation: string, cause: unknown) => void): LocalArchiveHandoff;
@@ -0,0 +1,21 @@
1
+ import { zookeeperCompaction, zookeeperCompactionGrant, } from "@fungi.computer/zookeeper";
2
+ import { logEvent } from "./log-event.js";
3
+ import { localArchiveWriter } from "./local-session-archives.js";
4
+ /**
5
+ * Zookeeper's compaction handoff over files beside the Agent store. An
6
+ * installed extension the owner chose keeps the single `compaction_handoff`
7
+ * slot: Zookeeper is then skipped and the skip is reported, never a failure.
8
+ */
9
+ export function localArchiveHandoff(storeDirectory, installedGrants, report = logEvent) {
10
+ const holder = installedGrants.find(({ capabilities }) => capabilities.includes("compaction_handoff"));
11
+ if (holder !== undefined) {
12
+ report("local-zookeeper-skipped", new Error(`Installed extension ${holder.id} holds the compaction handoff; Zookeeper archives are off for this Agent`));
13
+ return { roster: [], grants: [] };
14
+ }
15
+ return {
16
+ roster: [
17
+ zookeeperCompaction({ writer: localArchiveWriter(storeDirectory) }),
18
+ ],
19
+ grants: [zookeeperCompactionGrant],
20
+ };
21
+ }
@@ -1,7 +1,8 @@
1
1
  import { Mycelium } from "@fungi.computer/mycelium";
2
2
  import { defineExtension, type ExtensionCapabilityV0, type ExtensionGrantV0, type RosteredExtensionV0 } from "@fungi.computer/shiitake/extensions";
3
3
  import { z } from "zod";
4
- export type LocalExtensionFailureReason = "install" | "manifest" | "artifact" | "not-installed" | "not-enabled" | "no-tools" | "load";
4
+ import { type ExtensionSkillFolder } from "./local-skill-source.js";
5
+ export type LocalExtensionFailureReason = "install" | "manifest" | "artifact" | "not-installed" | "not-enabled" | "no-tools" | "no-skills" | "not-held" | "load";
5
6
  /** A typed refusal from the extension owner; never reported as usable. */
6
7
  export declare class LocalExtensionError extends Error {
7
8
  readonly name = "LocalExtensionError";
@@ -21,6 +22,10 @@ export type LocalExtensionHostV1 = Readonly<{
21
22
  }>;
22
23
  /** What an installed package's `./host` entry exports by default. */
23
24
  export type LocalExtensionFactoryV1 = (host: LocalExtensionHostV1) => RosteredExtensionV0 | Promise<RosteredExtensionV0>;
25
+ /** What `add` reports: the summary plus the names of the skills it ships. */
26
+ export type LocalInstalledExtension = LocalExtensionSummary & Readonly<{
27
+ skills: readonly string[];
28
+ }>;
24
29
  export type LocalExtensionSummary = Readonly<{
25
30
  id: string;
26
31
  revision: string;
@@ -50,6 +55,8 @@ export type LocalProjectExtensions = Readonly<{
50
55
  export type LocalAgentExtensions = Readonly<{
51
56
  roster: readonly RosteredExtensionV0[];
52
57
  grants: readonly ExtensionGrantV0[];
58
+ /** Skills folders of extensions this Agent opted into, read in place. */
59
+ skills: readonly ExtensionSkillFolder[];
53
60
  /** Extensions left out of this Agent's roster, and why. */
54
61
  diagnostics: readonly Readonly<{
55
62
  extensionId: string;
@@ -57,11 +64,11 @@ export type LocalAgentExtensions = Readonly<{
57
64
  }>[];
58
65
  }>;
59
66
  export type LocalExtensionsOptions = Readonly<{
60
- /** The user home that owns `.fungi/extensions`; defaults to the OS home. */
61
- home?: string | undefined;
67
+ /** The selected user home that owns `.fungi/extensions`. */
68
+ home: string;
62
69
  }>;
63
70
  /** Open the one extension owner for a user home. */
64
- export declare function localExtensions(options?: LocalExtensionsOptions): {
71
+ export declare function localExtensions(options: LocalExtensionsOptions): {
65
72
  root: string;
66
73
  /**
67
74
  * Install a package with the shipped Bun package manager (lifecycle
@@ -69,29 +76,68 @@ export declare function localExtensions(options?: LocalExtensionsOptions): {
69
76
  * is pinned to the commit Bun's lockfile resolved; updating is another
70
77
  * explicit `add`. Missing declared artifacts are a refusal.
71
78
  */
72
- add(source: string): Promise<LocalExtensionSummary>;
79
+ add(source: string): Promise<LocalInstalledExtension>;
73
80
  /** Remove the package and every activation that references it. */
74
81
  remove(id: string): Promise<boolean>;
75
82
  list(): Promise<readonly LocalExtensionSummary[]>;
76
83
  /** Enable the current revision's client part and programs for a project. */
77
84
  enableProject(projectRoot: string, id: string): Promise<string>;
78
85
  disableProject(projectRoot: string, id: string): Promise<boolean>;
86
+ /**
87
+ * The one check that an Agent holds an extension: refuses with
88
+ * `not-held`, naming every id it lacks. Callers needing a whole request
89
+ * judged before any effect call this first; `enableAgent` applies it too.
90
+ */
91
+ assertHeldByAgent(agentStoreDirectory: string, ids: readonly string[]): Promise<void>;
79
92
  /**
80
93
  * Grant one Agent store the current revision's declared capabilities.
81
94
  * Running work keeps its captured roster; the Agent applies this when it
82
- * next opens.
95
+ * next opens. A human (the CLI) names no requester. When an Agent asks, it
96
+ * must hold the extension itself.
83
97
  */
84
- enableAgent(storeDirectory: string, id: string): Promise<{
98
+ enableAgent(storeDirectory: string, id: string, requester?: Readonly<{
99
+ agentStoreDirectory: string;
100
+ }>): Promise<{
85
101
  revision: string;
86
102
  capabilities: ("state" | "attention" | "settled" | "send" | "timers" | "commands" | "projection" | "run_tools" | "files" | "prompt" | "compaction_handoff")[];
103
+ skills: boolean;
87
104
  }>;
105
+ /**
106
+ * Opt one Agent store into the skills the extension ships, without
107
+ * granting any tools. An existing activation keeps its revision and its
108
+ * capabilities; a new one carries none.
109
+ */
110
+ enableAgentSkills(storeDirectory: string, id: string): Promise<string>;
111
+ /**
112
+ * Turn only the skills off. Tools stay granted; an activation left with
113
+ * neither is removed.
114
+ */
115
+ disableAgentSkills(storeDirectory: string, id: string): Promise<boolean>;
88
116
  disableAgent(storeDirectory: string, id: string): Promise<boolean>;
89
117
  /** The project's enabled client entries and program launchers. */
90
118
  project(projectRoot: string): Promise<LocalProjectExtensions>;
91
- /**
92
- * Load the Agent's enabled server extensions through their host factory.
93
- * Each failure is isolated to its extension and reported.
94
- */
95
- agent(storeDirectory: string, projectRoot: string): Promise<LocalAgentExtensions>;
119
+ /** Capture validated records so one reload cannot mix file generations. */
120
+ captureAgent(): Promise<(storeDirectory: string, projectRoot: string) => Promise<Readonly<{
121
+ roster: readonly RosteredExtensionV0[];
122
+ grants: readonly ExtensionGrantV0[];
123
+ /** Skills folders of extensions this Agent opted into, read in place. */
124
+ skills: readonly ExtensionSkillFolder[];
125
+ /** Extensions left out of this Agent's roster, and why. */
126
+ diagnostics: readonly Readonly<{
127
+ extensionId: string;
128
+ error: Error;
129
+ }>[];
130
+ }>>>;
131
+ agent(storeDirectory: string, projectRoot: string): Promise<Readonly<{
132
+ roster: readonly RosteredExtensionV0[];
133
+ grants: readonly ExtensionGrantV0[];
134
+ /** Skills folders of extensions this Agent opted into, read in place. */
135
+ skills: readonly ExtensionSkillFolder[];
136
+ /** Extensions left out of this Agent's roster, and why. */
137
+ diagnostics: readonly Readonly<{
138
+ extensionId: string;
139
+ error: Error;
140
+ }>[];
141
+ }>>;
96
142
  };
97
143
  export type LocalExtensions = ReturnType<typeof localExtensions>;
@@ -1,6 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { access, constants, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
3
- import { homedir } from "node:os";
2
+ import { access, constants, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
4
3
  import { join, resolve, sep } from "node:path";
5
4
  import { pathToFileURL } from "node:url";
6
5
  import { Mycelium } from "@fungi.computer/mycelium";
@@ -10,12 +9,19 @@ import * as Schema from "effect/Schema";
10
9
  import lockfile from "proper-lockfile";
11
10
  import writeFileAtomic from "write-file-atomic";
12
11
  import { z } from "zod";
12
+ import { describeExtensionSkills, } from "./local-skill-source.js";
13
13
  /**
14
14
  * Installed Local extensions. One owner holds the package bytes under
15
15
  * `~/.fungi/extensions`, and the activations its typed operations write. A
16
16
  * project's client part and program launchers, and an Agent's tools, are
17
17
  * separate activations keyed by canonical path. Nothing under a project or
18
18
  * Agent store authorizes anything: a copied folder starts disabled.
19
+ *
20
+ * A package may also ship a `skills` folder. Its skills are read in place
21
+ * from the installed package (nothing is copied or symlinked) and only while
22
+ * the Agent's activation opts in, so disabling or removing the extension ends
23
+ * them. This covers Local installed extensions only; Hub-hosted extensions
24
+ * are not covered.
19
25
  */
20
26
  const capabilities = [
21
27
  "state",
@@ -36,6 +42,8 @@ const revisionPattern = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/u;
36
42
  const manifestSchema = z.strictObject({
37
43
  id: z.string().transform((value) => decodeExtensionId(value)),
38
44
  capabilities: z.array(z.enum(capabilities)).max(10).default([]),
45
+ /** Package-relative folder of skills, offered to an Agent that opts in. */
46
+ skills: z.string().startsWith("./").max(1_024).optional(),
39
47
  terminals: z
40
48
  .array(z.strictObject({
41
49
  id: identifier.max(64),
@@ -77,6 +85,7 @@ const revisionRecord = z.strictObject({
77
85
  terminals: z.array(terminalRecord),
78
86
  host: z.string().nullable(),
79
87
  client: z.string().nullable(),
88
+ skills: z.string().nullable(),
80
89
  });
81
90
  const installedSchema = z.strictObject({
82
91
  extensions: z.record(z.string(), z.strictObject({
@@ -89,6 +98,8 @@ const activationsSchema = z.strictObject({
89
98
  agents: z.record(z.string(), z.record(z.string(), z.strictObject({
90
99
  revision: z.string(),
91
100
  capabilities: z.array(z.enum(capabilities)),
101
+ /** The opt-in to the revision's skills folder; separate from tools. */
102
+ skills: z.boolean(),
92
103
  }))),
93
104
  });
94
105
  const lockSchema = z.looseObject({
@@ -112,6 +123,24 @@ export class LocalExtensionError extends Error {
112
123
  this.reason = reason;
113
124
  }
114
125
  }
126
+ /**
127
+ * An Agent holds an extension when its store has granted tools, covering every
128
+ * capability of the current revision. Opting into only the extension's skills
129
+ * is not holding it: skills carry no authority to hand its tools on.
130
+ */
131
+ function assertAgentHolds(held, installed, ids) {
132
+ const missing = ids.filter((id) => {
133
+ const activation = held?.[id];
134
+ if (activation === undefined || activation.capabilities.length === 0)
135
+ return true;
136
+ const entry = installed.extensions[id];
137
+ const needed = (entry === undefined ? undefined : entry.revisions[entry.current])
138
+ ?.capabilities ?? [];
139
+ return !needed.every((capability) => activation.capabilities.includes(capability));
140
+ });
141
+ if (missing.length > 0)
142
+ throw new LocalExtensionError("not-held", `An Agent cannot give ${missing.join(", ")} to another Agent: it does not hold ${missing.length === 1 ? "it" : "them"} itself`);
143
+ }
115
144
  function failure(cause) {
116
145
  return cause instanceof Error ? cause : new Error(String(cause));
117
146
  }
@@ -121,11 +150,11 @@ function absent(cause) {
121
150
  (cause.code === "ENOENT" || cause.code === "ENOTDIR"));
122
151
  }
123
152
  /** Open the one extension owner for a user home. */
124
- export function localExtensions(options = {}) {
125
- const root = join(resolve(options.home ?? homedir()), ".fungi", "extensions");
153
+ export function localExtensions(options) {
154
+ const root = join(resolve(options.home), ".fungi", "extensions");
126
155
  const installedPath = join(root, "installed.json");
127
156
  const activationsPath = join(root, "activations.json");
128
- async function readJson(path, schema, empty) {
157
+ async function readJson(path, schema, empty, remedy) {
129
158
  let text;
130
159
  try {
131
160
  text = await readFile(path, "utf8");
@@ -135,10 +164,13 @@ export function localExtensions(options = {}) {
135
164
  return empty;
136
165
  throw cause;
137
166
  }
138
- return schema.parse(JSON.parse(text));
167
+ const parsed = schema.safeParse(JSON.parse(text));
168
+ if (!parsed.success)
169
+ throw new LocalExtensionError("install", `${path} is from an earlier extension format; ${remedy}`, { cause: parsed.error });
170
+ return parsed.data;
139
171
  }
140
- const readInstalled = () => readJson(installedPath, installedSchema, { extensions: {} });
141
- const readActivations = () => readJson(activationsPath, activationsSchema, { projects: {}, agents: {} });
172
+ const readInstalled = () => readJson(installedPath, installedSchema, { extensions: {} }, "delete it and run `fungi extension add` again");
173
+ const readActivations = () => readJson(activationsPath, activationsSchema, { projects: {}, agents: {} }, "delete it, then re-enable your extensions");
142
174
  /** Serialize owner writes across CLI and service processes. */
143
175
  async function update(change) {
144
176
  await mkdir(root, { recursive: true, mode: 0o700 });
@@ -195,6 +227,27 @@ export function localExtensions(options = {}) {
195
227
  }
196
228
  return path;
197
229
  }
230
+ /** A declared skills folder must be a directory inside the package. */
231
+ async function declaredSkills(directory, declared) {
232
+ if (declared === undefined)
233
+ return null;
234
+ const path = resolve(directory, declared);
235
+ if (!path.startsWith(`${directory}${sep}`))
236
+ throw new LocalExtensionError("manifest", "skills must point inside its package");
237
+ try {
238
+ const real = await realpath(path);
239
+ if (!real.startsWith(`${await realpath(directory)}${sep}`))
240
+ throw new LocalExtensionError("manifest", "skills must point inside its package");
241
+ if (!(await stat(real)).isDirectory())
242
+ throw new LocalExtensionError("artifact", `The package declares skills but ${declared} is not a directory`);
243
+ }
244
+ catch (cause) {
245
+ if (cause instanceof LocalExtensionError)
246
+ throw cause;
247
+ throw new LocalExtensionError("artifact", `The package declares skills but ${declared} is not in the installed package`, { cause });
248
+ }
249
+ return path;
250
+ }
198
251
  return {
199
252
  root,
200
253
  /**
@@ -246,6 +299,12 @@ export function localExtensions(options = {}) {
246
299
  const manifest = selected.pkg.fungi;
247
300
  const host = await declaredEntry(selected.directory, selected.pkg.exports, "./host");
248
301
  const client = await declaredEntry(selected.directory, selected.pkg.exports, "./client");
302
+ const skills = await declaredSkills(selected.directory, manifest.skills);
303
+ const skillNames = skills === null
304
+ ? []
305
+ : (await describeExtensionSkills({ extensionId: manifest.id, directory: skills }, AbortSignal.timeout(10_000))).skills.map((skill) => skill.name);
306
+ if (skills !== null && skillNames.length === 0)
307
+ throw new LocalExtensionError("artifact", `${manifest.id} declares skills but ${manifest.skills ?? ""} contains none`);
249
308
  if (manifest.capabilities.length > 0 && host === null)
250
309
  throw new LocalExtensionError("artifact", `${manifest.id} declares Agent capabilities but its package exports no ./host entry`);
251
310
  for (const terminal of manifest.terminals) {
@@ -282,6 +341,9 @@ export function localExtensions(options = {}) {
282
341
  client: client === null
283
342
  ? null
284
343
  : join(target, client.slice(staging.length + sep.length)),
344
+ skills: skills === null
345
+ ? null
346
+ : join(target, skills.slice(staging.length + sep.length)),
285
347
  };
286
348
  await update((installed) => {
287
349
  const existing = installed.extensions[manifest.id];
@@ -301,6 +363,7 @@ export function localExtensions(options = {}) {
301
363
  commit,
302
364
  capabilities: record.capabilities,
303
365
  terminals: record.terminals.map((terminal) => terminal.id),
366
+ skills: skillNames,
304
367
  };
305
368
  }
306
369
  finally {
@@ -365,22 +428,86 @@ export function localExtensions(options = {}) {
365
428
  return true;
366
429
  });
367
430
  },
431
+ /**
432
+ * The one check that an Agent holds an extension: refuses with
433
+ * `not-held`, naming every id it lacks. Callers needing a whole request
434
+ * judged before any effect call this first; `enableAgent` applies it too.
435
+ */
436
+ async assertHeldByAgent(agentStoreDirectory, ids) {
437
+ const store = await realpath(agentStoreDirectory);
438
+ const [installed, activations] = await Promise.all([
439
+ readInstalled(),
440
+ readActivations(),
441
+ ]);
442
+ assertAgentHolds(activations.agents[store], installed, ids);
443
+ },
368
444
  /**
369
445
  * Grant one Agent store the current revision's declared capabilities.
370
446
  * Running work keeps its captured roster; the Agent applies this when it
371
- * next opens.
447
+ * next opens. A human (the CLI) names no requester. When an Agent asks, it
448
+ * must hold the extension itself.
372
449
  */
373
- async enableAgent(storeDirectory, id) {
450
+ async enableAgent(storeDirectory, id, requester) {
374
451
  const store = await realpath(storeDirectory);
452
+ const requesterStore = requester === undefined
453
+ ? undefined
454
+ : await realpath(requester.agentStoreDirectory);
375
455
  return update((installed, activations) => {
456
+ if (requesterStore !== undefined)
457
+ assertAgentHolds(activations.agents[requesterStore], installed, [id]);
376
458
  const { revision, record } = currentRevision(installed, id);
377
459
  if (record.host === null || record.capabilities.length === 0)
378
460
  throw new LocalExtensionError("no-tools", `Extension ${id} declares no Agent capabilities`);
461
+ const skills = activations.agents[store]?.[id]?.skills === true &&
462
+ record.skills !== null;
463
+ activations.agents[store] = {
464
+ ...activations.agents[store],
465
+ [id]: { revision, capabilities: record.capabilities, skills },
466
+ };
467
+ return { revision, capabilities: record.capabilities, skills };
468
+ });
469
+ },
470
+ /**
471
+ * Opt one Agent store into the skills the extension ships, without
472
+ * granting any tools. An existing activation keeps its revision and its
473
+ * capabilities; a new one carries none.
474
+ */
475
+ async enableAgentSkills(storeDirectory, id) {
476
+ const store = await realpath(storeDirectory);
477
+ return update((installed, activations) => {
478
+ const current = currentRevision(installed, id);
479
+ const prior = activations.agents[store]?.[id];
480
+ const revision = prior?.revision ?? current.revision;
481
+ const record = installed.extensions[id]?.revisions[revision];
482
+ if (!record?.skills)
483
+ throw new LocalExtensionError("no-skills", `Extension ${id}@${revision} ships no skills`);
379
484
  activations.agents[store] = {
380
485
  ...activations.agents[store],
381
- [id]: { revision, capabilities: record.capabilities },
486
+ [id]: {
487
+ revision,
488
+ capabilities: prior?.capabilities ?? [],
489
+ skills: true,
490
+ },
382
491
  };
383
- return { revision, capabilities: record.capabilities };
492
+ return revision;
493
+ });
494
+ },
495
+ /**
496
+ * Turn only the skills off. Tools stay granted; an activation left with
497
+ * neither is removed.
498
+ */
499
+ async disableAgentSkills(storeDirectory, id) {
500
+ const store = await realpath(storeDirectory);
501
+ return update((_installed, activations) => {
502
+ const enabled = activations.agents[store];
503
+ const activation = enabled?.[id];
504
+ if (enabled === undefined || activation?.skills !== true)
505
+ return false;
506
+ if (activation.capabilities.length === 0)
507
+ Reflect.deleteProperty(enabled, id);
508
+ else
509
+ enabled[id] = { ...activation, skills: false };
510
+ return true;
384
511
  });
385
512
  },
386
513
  async disableAgent(storeDirectory, id) {
@@ -432,53 +559,66 @@ export function localExtensions(options = {}) {
432
559
  }
433
560
  return { clients, terminals, diagnostics };
434
561
  },
435
- /**
436
- * Load the Agent's enabled server extensions through their host factory.
437
- * Each failure is isolated to its extension and reported.
438
- */
439
- async agent(storeDirectory, projectRoot) {
440
- const store = await realpath(storeDirectory);
562
+ /** Capture validated records so one reload cannot mix file generations. */
563
+ async captureAgent() {
441
564
  const [installed, activations] = await Promise.all([
442
565
  readInstalled(),
443
566
  readActivations(),
444
567
  ]);
445
- const roster = [];
446
- const grants = [];
447
- const diagnostics = [];
448
- for (const [id, activation] of Object.entries(activations.agents[store] ?? {})) {
449
- const record = installed.extensions[id]?.revisions[activation.revision];
450
- try {
451
- if (record?.host === null || record === undefined)
452
- throw new LocalExtensionError("not-installed", `Extension ${id}@${activation.revision} is enabled but its host entry is not installed`);
453
- const module = hostModuleSchema.parse(await import(pathToFileURL(record.host).href));
454
- const extension = await module.default({
455
- runtimeDirectory: await runtimeDirectory(projectRoot, id),
456
- defineExtension,
457
- Mycelium,
458
- z,
459
- });
460
- const parsed = rosteredSchema.parse(extension);
461
- if (parsed.manifest.id !== id)
462
- throw new LocalExtensionError("load", `Extension ${id} host returned ${parsed.manifest.id}`);
463
- const requested = new Set(parsed.manifest.requests);
464
- roster.push(z
465
- .custom((value) => rosteredSchema.safeParse(value).success)
466
- .parse(extension));
467
- grants.push({
468
- id: decodeExtensionId(id),
469
- capabilities: activation.capabilities.filter((capability) => requested.has(capability)),
470
- });
471
- }
472
- catch (cause) {
473
- diagnostics.push({
474
- extensionId: id,
475
- error: cause instanceof LocalExtensionError
476
- ? cause
477
- : new LocalExtensionError("load", `Extension ${id} could not load`, { cause: failure(cause) }),
478
- });
479
- }
480
- }
481
- return { roster, grants, diagnostics };
568
+ return (storeDirectory, projectRoot) => loadAgent(installed, activations, storeDirectory, projectRoot);
569
+ },
570
+ async agent(storeDirectory, projectRoot) {
571
+ return loadAgent(await readInstalled(), await readActivations(), storeDirectory, projectRoot);
482
572
  },
483
573
  };
574
+ async function loadAgent(installed, activations, storeDirectory, projectRoot) {
575
+ const store = await realpath(storeDirectory);
576
+ const roster = [];
577
+ const grants = [];
578
+ const skills = [];
579
+ const diagnostics = [];
580
+ for (const [id, activation] of Object.entries(activations.agents[store] ?? {})) {
581
+ const record = installed.extensions[id]?.revisions[activation.revision];
582
+ try {
583
+ if (record === undefined)
584
+ throw new LocalExtensionError("not-installed", `Extension ${id}@${activation.revision} is enabled but not installed`);
585
+ if (activation.skills && record.skills !== null)
586
+ skills.push({ extensionId: id, directory: record.skills });
587
+ // A skills-only activation grants no tools and loads no host code.
588
+ if (activation.capabilities.length === 0)
589
+ continue;
590
+ if (record.host === null)
591
+ throw new LocalExtensionError("not-installed", `Extension ${id}@${activation.revision} is enabled but its host entry is not installed`);
592
+ const module = hostModuleSchema.parse(await import(pathToFileURL(record.host).href));
593
+ const extension = await module.default({
594
+ runtimeDirectory: await runtimeDirectory(projectRoot, id),
595
+ defineExtension,
596
+ Mycelium,
597
+ z,
598
+ });
599
+ const parsed = rosteredSchema.parse(extension);
600
+ if (parsed.manifest.id !== id)
601
+ throw new LocalExtensionError("load", `Extension ${id} host returned ${parsed.manifest.id}`);
602
+ const requested = new Set(parsed.manifest.requests);
603
+ roster.push(z
604
+ .custom((value) => rosteredSchema.safeParse(value).success)
605
+ .parse(extension));
606
+ grants.push({
607
+ id: decodeExtensionId(id),
608
+ capabilities: activation.capabilities.filter((capability) => requested.has(capability)),
609
+ });
610
+ }
611
+ catch (cause) {
612
+ diagnostics.push({
613
+ extensionId: id,
614
+ error: cause instanceof LocalExtensionError
615
+ ? cause
616
+ : new LocalExtensionError("load", `Extension ${id} could not load`, { cause: failure(cause) }),
617
+ });
618
+ }
619
+ }
620
+ // Same skill name in two extensions: the lower extension id wins, always.
621
+ skills.sort((a, b) => a.extensionId.localeCompare(b.extensionId));
622
+ return { roster, grants, skills, diagnostics };
623
+ }
484
624
  }