@fungi.computer/graft 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,154 @@
1
- # Temporary Holding Version
1
+ # Graft (`@fungi.computer/graft`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ This package presents an authorized Shiitake Session through the stable Agent
4
+ Client Protocol (ACP) v1. Editor and host integrations use it to create, load,
5
+ prompt, and observe Sessions while the host remains responsible for
6
+ authorization.
7
+
8
+ Just want Fungi in Zed or another ACP editor? Run `fungi acp` — see
9
+ [Use Fungi in your editor](https://docs.fungi.computer/guides/use-fungi-in-your-editor/).
10
+ Graft is for people embedding ACP in their own host.
11
+
12
+ ## Install or set up
13
+
14
+ Graft is a public package qualified with packed npm consumers. Qualification
15
+ does not publish an npm release; check the registry before choosing a released
16
+ version. To build the source workspace and qualify its tarballs:
17
+
18
+ ```sh
19
+ pnpm install --frozen-lockfile
20
+ pnpm turbo:run run build --filter=@fungi.computer/graft
21
+ pnpm --filter @fungi.computer/graft run pack
22
+ pnpm --filter @fungi.computer/graft run stranger
23
+ ```
24
+
25
+ The source workspace supplies Graft's workspace dependencies. The stranger proof
26
+ packs the dependency closure and installs it in a fresh consumer; a standalone
27
+ release needs those dependencies published too. Strict TypeScript consumers also
28
+ install `@modelcontextprotocol/sdk@1.30.0`: the transitive Google GenAI
29
+ declarations reference this optional peer. Both packed proofs include it; the
30
+ stranger proof keeps library checking enabled.
31
+
32
+ ## Quick start
33
+
34
+ Just want Fungi in Zed or another ACP editor? Run `fungi acp` — see
35
+ [Use Fungi in your editor](https://docs.fungi.computer/guides/use-fungi-in-your-editor/).
36
+ Graft is for people embedding ACP in their own host.
37
+
38
+ This self-contained example initializes an ACP connection without granting
39
+ access to any Session. Replace the refusing directory with your host's
40
+ authorized Session directory before accepting create or load requests.
41
+
42
+ ```ts
43
+ import * as acp from "@agentclientprotocol/sdk";
44
+ import { createHttpStream } from "@agentclientprotocol/sdk/experimental/http-client";
45
+ import {
46
+ agentAcpMethods,
47
+ type AuthorizedShiitakeSessionDirectory,
48
+ } from "@fungi.computer/graft";
49
+ import { createShiitakeAcpServer } from "@fungi.computer/graft/server";
50
+
51
+ const directory: AuthorizedShiitakeSessionDirectory = {
52
+ create() {
53
+ return Promise.reject(new Error("Session access is not granted"));
54
+ },
55
+ load() {
56
+ return Promise.reject(new Error("Session access is not granted"));
57
+ },
58
+ };
59
+ const server = createShiitakeAcpServer(directory);
60
+ const stream = createHttpStream("https://agent.test/acp", {
61
+ fetch: (input, init) => server.handleRequest(new Request(input, init)),
62
+ });
63
+ try {
64
+ await acp
65
+ .client({ name: "graft-example" })
66
+ .connectWith(stream, async (ctx) => {
67
+ const initialized = await ctx.request(agentAcpMethods.initialize, {
68
+ protocolVersion: acp.PROTOCOL_VERSION,
69
+ });
70
+ console.log(initialized.protocolVersion);
71
+ });
72
+ } finally {
73
+ await stream.writable.close();
74
+ await server.close();
75
+ }
76
+ ```
77
+
78
+ The fetch adapter calls the server in-process; it makes no network request and
79
+ needs no model or credentials. The packed runtime proof in
80
+ [`test/stranger/runtime.mjs`](test/stranger/runtime.mjs) exercises this flow on
81
+ Node. [`test/index.test.ts`](test/index.test.ts) additionally proves Session
82
+ creation, prompting and cleanup through the maintained ACP client.
83
+
84
+ ## Core concepts
85
+
86
+ - **The directory owns access.** Its `create` and `load` methods return a
87
+ Session binding only after the host authorizes it. It can validate the working
88
+ directory, additional directories, and MCP servers before opening the Session.
89
+ - **Shiitake owns Session state.** The ACP adapter projects messages, tool
90
+ calls, progress, cancellation, and terminal results. It does not own the
91
+ transcript or delete the durable Session.
92
+ - **An ACP connection owns its lease.** Closing a Session or connection releases
93
+ its host-provided client lease and observation. An active prompt is cancelled
94
+ and allowed to settle for up to five seconds by default when its Session
95
+ closes.
96
+ - **The server is an adapter.** `createShiitakeAcpServer` exposes the same agent
97
+ through the maintained Streamable HTTP transport; it does not supply
98
+ authorization by itself.
99
+
100
+ ## API reference
101
+
102
+ The package exports `createShiitakeAcpAgent`, the authorization and adapter
103
+ types, and `agentAcpMethods` from [`src/index.ts`](src/index.ts). The HTTP
104
+ server is exported separately from [`src/server.ts`](src/server.ts). The
105
+ host-owned directory adapter lives in
106
+ [`src/session-directory.ts`](src/session-directory.ts). The generated
107
+ [API reference](docs/api/README.md) is included in the tarball.
108
+
109
+ ## Examples
110
+
111
+ - [`test/index.test.ts`](test/index.test.ts) proves create/load/prompt behavior,
112
+ streamed message projection, cancellation and cleanup through the maintained
113
+ ACP client.
114
+ - The private Local stdio host currently rejects ACP `additionalDirectories` and
115
+ `mcpServers`; other hosts may support them when their authorization boundary
116
+ permits them.
117
+
118
+ ## Limits and errors
119
+
120
+ The adapter supports text and image prompts, embedded text or image resources,
121
+ and text or image tool results. Audio and other binary resources are refused. A
122
+ host decides which directories, MCP servers, and Sessions the connection may
123
+ use. ACP create requests do not provide their own stable retry identity, so the
124
+ directory generates a request identity for Shiitake. Callers must close the ACP
125
+ connection so watches and authorization leases are released; closing the lease
126
+ does not delete the Session.
127
+
128
+ ## Contribute and license
129
+
130
+ Run `pnpm --filter @fungi.computer/graft test` for the ACP protocol and
131
+ lifecycle tests, and `pnpm --filter @fungi.computer/graft build` to compile the
132
+ package. The package uses the MIT license in [`LICENSE`](LICENSE).
133
+
134
+ ## Editor capabilities
135
+
136
+ The adapter uses ACP SDK 1.7 and stable protocol v1. Initialization returns the
137
+ supported protocol version even when an editor offers another version; the
138
+ editor decides whether it can continue. Session listing is advertised only when
139
+ the authorized host directory supplies it. Local lists saved project Sessions.
140
+
141
+ A host may supply a terminal login method. It is advertised only when the editor
142
+ supports terminal authentication; the editor runs the login command in a
143
+ separate interactive process. Hosted directories do not acquire Local
144
+ credentials.
145
+
146
+ Text resources retain their URI and contents in prompt context. Resource links
147
+ retain their name and URI for the Agent's authorized tools to resolve. Images,
148
+ including embedded image resources, enter Shiitake's bounded prompt admission
149
+ and persist as Pi image content. History replays images through ACP. Audio and
150
+ other binary resources return `invalid_params`; they are not silently discarded.
151
+
152
+ Protocol references:
153
+ [initialization](https://agentclientprotocol.com/protocol/v1/initialization) and
154
+ [content](https://agentclientprotocol.com/protocol/v1/content).
@@ -0,0 +1,95 @@
1
+ import * as acp from "@agentclientprotocol/sdk";
2
+ import type { ShiitakeSession } from "@fungi.computer/shiitake/client";
3
+ /** Maintained ACP method descriptors advertised by this agent adapter. */
4
+ export declare const agentAcpMethods: {
5
+ readonly initialize: "initialize";
6
+ readonly authenticate: "authenticate";
7
+ readonly logout: "logout";
8
+ readonly providers: {
9
+ readonly list: "providers/list";
10
+ readonly set: "providers/set";
11
+ readonly disable: "providers/disable";
12
+ };
13
+ readonly session: {
14
+ readonly new: "session/new";
15
+ readonly load: "session/load";
16
+ readonly list: "session/list";
17
+ readonly delete: "session/delete";
18
+ readonly fork: "session/fork";
19
+ readonly resume: "session/resume";
20
+ readonly close: "session/close";
21
+ readonly setMode: "session/set_mode";
22
+ readonly setConfigOption: "session/set_config_option";
23
+ readonly prompt: "session/prompt";
24
+ readonly cancel: "session/cancel";
25
+ };
26
+ readonly nes: {
27
+ readonly start: "nes/start";
28
+ readonly suggest: "nes/suggest";
29
+ readonly accept: "nes/accept";
30
+ readonly reject: "nes/reject";
31
+ readonly close: "nes/close";
32
+ };
33
+ readonly document: {
34
+ readonly didOpen: "document/didOpen";
35
+ readonly didChange: "document/didChange";
36
+ readonly didClose: "document/didClose";
37
+ readonly didSave: "document/didSave";
38
+ readonly didFocus: "document/didFocus";
39
+ };
40
+ };
41
+ /** A Session returned by the host's authorization boundary. */
42
+ export type AuthorizedShiitakeSession = Readonly<{
43
+ /** Authorized live Session client. */
44
+ session: ShiitakeSession;
45
+ /** Durable identity of the authorized Session. */
46
+ sessionId: string;
47
+ /** Release this ACP client's authorization lease; retain the durable Session. */
48
+ close: () => Promise<void>;
49
+ }>;
50
+ /** Directory owner used by ACP lifecycle callbacks. */
51
+ export type AuthorizedShiitakeSessionDirectory = Readonly<{
52
+ /** Authorize and create a Session with the requested context. */
53
+ create: (input: {
54
+ cwd: string;
55
+ additionalDirectories: readonly string[];
56
+ mcpServers: readonly acp.McpServer[];
57
+ signal: AbortSignal;
58
+ }) => Promise<AuthorizedShiitakeSession>;
59
+ /** Enumerate only Sessions within this host's authorized directory. */
60
+ list?: (input: {
61
+ cwd?: string;
62
+ cursor?: string;
63
+ signal: AbortSignal;
64
+ }) => Promise<acp.ListSessionsResponse>;
65
+ /** Authorize and open an existing Session with the requested context. */
66
+ load: (input: {
67
+ sessionId: string;
68
+ cwd: string;
69
+ additionalDirectories: readonly string[];
70
+ mcpServers: readonly acp.McpServer[];
71
+ signal: AbortSignal;
72
+ }) => Promise<AuthorizedShiitakeSession>;
73
+ }>;
74
+ /** Host directory, agent identity and lifecycle options for an ACP adapter. */
75
+ export type ShiitakeAcpAdapterOptions = Readonly<{
76
+ /** Host-owned Session authorization boundary. */
77
+ directory: AuthorizedShiitakeSessionDirectory;
78
+ /** Agent name reported to the editor. */
79
+ name?: string;
80
+ /** Host login command, appended by clients that support terminal auth. */
81
+ terminalAuth?: acp.AuthMethodTerminal;
82
+ /**
83
+ * How long `session/close` waits for an active prompt to settle after its
84
+ * cancel before detaching the prompt's observation. Defaults to 5 seconds.
85
+ */
86
+ closeSettleMs?: number;
87
+ }>;
88
+ /**
89
+ * Projects ACP stable v1 onto Sessions from one host-authorized directory.
90
+ *
91
+ * The host directory owns Session creation/loading and authorization. ACP
92
+ * request IDs are retained for correlation while Shiitake generates its own
93
+ * idempotency key and returns its own accepted command ID.
94
+ */
95
+ export declare function createShiitakeAcpAgent(options: ShiitakeAcpAdapterOptions): acp.AgentApp;
@@ -0,0 +1,530 @@
1
+ import * as acp from "@agentclientprotocol/sdk";
2
+ /** Maintained ACP method descriptors advertised by this agent adapter. */
3
+ export const agentAcpMethods = acp.methods.agent;
4
+ const DEFAULT_CLOSE_SETTLE_MS = 5_000;
5
+ function isContentParts(content) {
6
+ return Array.isArray(content);
7
+ }
8
+ function contentBlocks(content) {
9
+ return !isContentParts(content)
10
+ ? [acpText(content)]
11
+ : content.map((part) => part.type === "text"
12
+ ? acpText(part.text)
13
+ : { type: "image", data: part.data, mimeType: part.mimeType });
14
+ }
15
+ function textFromAssistant(content) {
16
+ return content
17
+ .filter((part) => part.type === "text")
18
+ .map((part) => part.text)
19
+ .join("");
20
+ }
21
+ function thinkingFromAssistant(content) {
22
+ return content
23
+ .filter((part) => part.type === "thinking")
24
+ .map((part) => part.thinking)
25
+ .join("");
26
+ }
27
+ function acpText(text) {
28
+ return { type: "text", text };
29
+ }
30
+ function notify(client, sessionId, update) {
31
+ return client.notify(acp.methods.client.session.update, {
32
+ sessionId,
33
+ update,
34
+ });
35
+ }
36
+ /**
37
+ * Return the part of `next` that extends what was sent. ACP chunks cannot be
38
+ * retracted, so a revision that does not extend the sent text sends nothing.
39
+ */
40
+ function unsent(sent, next) {
41
+ return next.startsWith(sent) ? next.slice(sent.length) : "";
42
+ }
43
+ async function projectMessage(client, sessionId, message, streamed = { text: "", thinking: "" }) {
44
+ const value = message.value;
45
+ if (value.role === "system")
46
+ return;
47
+ if (value.role === "user") {
48
+ for (const content of contentBlocks(value.content))
49
+ await notify(client, sessionId, {
50
+ sessionUpdate: "user_message_chunk",
51
+ content,
52
+ });
53
+ return;
54
+ }
55
+ if (value.role === "assistant") {
56
+ const thinking = unsent(streamed.thinking, thinkingFromAssistant(value.content));
57
+ if (thinking)
58
+ await notify(client, sessionId, {
59
+ sessionUpdate: "agent_thought_chunk",
60
+ content: acpText(thinking),
61
+ });
62
+ const text = unsent(streamed.text, textFromAssistant(value.content));
63
+ if (text)
64
+ await notify(client, sessionId, {
65
+ sessionUpdate: "agent_message_chunk",
66
+ content: acpText(text),
67
+ });
68
+ for (const part of value.content) {
69
+ if (part.type !== "toolCall")
70
+ continue;
71
+ await notify(client, sessionId, {
72
+ sessionUpdate: "tool_call",
73
+ toolCallId: part.id,
74
+ title: part.name,
75
+ kind: "other",
76
+ status: "in_progress",
77
+ rawInput: part.arguments,
78
+ });
79
+ }
80
+ return;
81
+ }
82
+ const toolResult = value;
83
+ await notify(client, sessionId, {
84
+ sessionUpdate: "tool_call_update",
85
+ toolCallId: toolResult.toolCallId,
86
+ status: toolResult.isError ? "failed" : "completed",
87
+ content: contentBlocks(toolResult.content).map((content) => ({
88
+ type: "content",
89
+ content,
90
+ })),
91
+ rawOutput: toolResult.details,
92
+ });
93
+ }
94
+ async function projectProgress(client, sessionId, progress, turn) {
95
+ if (turn.projected.has(progress.messageId))
96
+ return;
97
+ const sent = turn.streamed.get(progress.messageId) ?? {
98
+ text: "",
99
+ thinking: "",
100
+ };
101
+ const thinking = unsent(sent.thinking, progress.thinking);
102
+ const text = unsent(sent.text, progress.text);
103
+ turn.streamed.set(progress.messageId, {
104
+ text: sent.text + text,
105
+ thinking: sent.thinking + thinking,
106
+ });
107
+ if (thinking)
108
+ await notify(client, sessionId, {
109
+ sessionUpdate: "agent_thought_chunk",
110
+ content: acpText(thinking),
111
+ });
112
+ if (text)
113
+ await notify(client, sessionId, {
114
+ sessionUpdate: "agent_message_chunk",
115
+ content: acpText(text),
116
+ });
117
+ }
118
+ async function projectTurnMessages(client, sessionId, messages, turn) {
119
+ for (const message of messages) {
120
+ if (message.commandId !== turn.commandId)
121
+ continue;
122
+ if (turn.projected.has(message.id))
123
+ continue;
124
+ turn.projected.add(message.id);
125
+ // The ACP client sent this turn's prompt; echoing it would duplicate it.
126
+ if (message.value.role === "user")
127
+ continue;
128
+ await projectMessage(client, sessionId, message, turn.streamed.get(message.id));
129
+ turn.streamed.delete(message.id);
130
+ }
131
+ }
132
+ async function projectFrame(client, sessionId, frame, turn) {
133
+ if (frame.kind === "progress") {
134
+ await projectProgress(client, sessionId, frame.progress, turn);
135
+ return [];
136
+ }
137
+ if (frame.kind === "reset") {
138
+ // A reset replaces the baseline mid-turn. Project this turn's messages
139
+ // that were not already sent; everything else is history. Later progress
140
+ // frames carry cumulative text, so live progress resumes from its delta.
141
+ await projectTurnMessages(client, sessionId, frame.baseline.messages, turn);
142
+ return frame.baseline.terminals;
143
+ }
144
+ await projectTurnMessages(client, sessionId, frame.messages, turn);
145
+ return frame.terminals;
146
+ }
147
+ function stopReason(outcome) {
148
+ if (outcome === "completed")
149
+ return "end_turn";
150
+ if (outcome === "cancelled")
151
+ return "cancelled";
152
+ throw new Error(`Session turn ended with ${outcome}`);
153
+ }
154
+ function assertSessionId(sessionId) {
155
+ if (sessionId.length === 0)
156
+ throw new Error("The Session owner returned an empty identity");
157
+ }
158
+ function promptInput(prompt) {
159
+ const text = [];
160
+ const images = [];
161
+ for (const block of prompt) {
162
+ switch (block.type) {
163
+ case "text":
164
+ text.push(block.text);
165
+ break;
166
+ case "image":
167
+ images.push({
168
+ type: "image",
169
+ data: block.data,
170
+ mimeType: block.mimeType,
171
+ });
172
+ break;
173
+ case "resource_link":
174
+ text.push(`\nReferenced resource ${JSON.stringify(block.name)}: ${JSON.stringify(block.uri)}\n`);
175
+ break;
176
+ case "resource": {
177
+ const resource = block.resource;
178
+ text.push(`\nResource ${JSON.stringify(resource.uri)} (${resource.mimeType ?? "unspecified"}):\n`);
179
+ if ("text" in resource)
180
+ text.push(resource.text);
181
+ else if (resource.mimeType?.startsWith("image/") === true)
182
+ images.push({
183
+ type: "image",
184
+ data: resource.blob,
185
+ mimeType: resource.mimeType,
186
+ });
187
+ else
188
+ throw acp.RequestError.invalidParams(undefined, "Binary resources require a supported image MIME type; send other context as text");
189
+ text.push("\nEnd resource\n");
190
+ break;
191
+ }
192
+ case "audio":
193
+ throw acp.RequestError.invalidParams(undefined, "Audio prompts are unsupported");
194
+ }
195
+ }
196
+ return { text: text.join(""), images };
197
+ }
198
+ /** Resolve true when `work` settles within `ms`, false when the bound passes. */
199
+ function settlesWithin(work, ms) {
200
+ let timer;
201
+ return Promise.race([
202
+ work.then(() => true),
203
+ new Promise((resolve) => {
204
+ timer = setTimeout(() => {
205
+ resolve(false);
206
+ }, ms);
207
+ }),
208
+ ]).finally(() => {
209
+ clearTimeout(timer);
210
+ });
211
+ }
212
+ function asError(cause) {
213
+ return cause instanceof Error ? cause : new Error(String(cause));
214
+ }
215
+ function closeWatchOnce(closed, watch) {
216
+ const existing = closed.get(watch);
217
+ if (existing)
218
+ return existing;
219
+ const closing = Promise.resolve().then(() => watch.close());
220
+ closed.set(watch, closing);
221
+ return closing;
222
+ }
223
+ /** Stop observing a turn; its prompt request then ends as detached. */
224
+ function detach(turn, closedWatches) {
225
+ turn.detached = true;
226
+ if (turn.watch === undefined)
227
+ return Promise.resolve({ kind: "closed" });
228
+ return closeWatchOnce(closedWatches, turn.watch).then(() => ({ kind: "closed" }), (cause) => ({ kind: "failed", error: asError(cause) }));
229
+ }
230
+ /** Re-read teardown state after an asynchronous owner operation. */
231
+ function assertPromptAttached(turn, state) {
232
+ if (turn.detached || state.closed !== undefined)
233
+ throw new Error("The ACP connection closed before prompt admission");
234
+ }
235
+ function closeOnce(closed, binding) {
236
+ const existing = closed.get(binding);
237
+ if (existing)
238
+ return existing;
239
+ const closing = Promise.resolve().then(() => binding.close());
240
+ closed.set(binding, closing);
241
+ return closing;
242
+ }
243
+ /**
244
+ * Projects ACP stable v1 onto Sessions from one host-authorized directory.
245
+ *
246
+ * The host directory owns Session creation/loading and authorization. ACP
247
+ * request IDs are retained for correlation while Shiitake generates its own
248
+ * idempotency key and returns its own accepted command ID.
249
+ */
250
+ export function createShiitakeAcpAgent(options) {
251
+ const { directory } = options;
252
+ const closeSettleMs = options.closeSettleMs ?? DEFAULT_CLOSE_SETTLE_MS;
253
+ const closedBindings = new WeakMap();
254
+ const closedWatches = new WeakMap();
255
+ const app = acp.agent({ name: options.name ?? "Shiitake" });
256
+ let connectionLease;
257
+ function stateFor() {
258
+ const state = connectionLease?.state;
259
+ if (!state || !connectionLease?.accepting)
260
+ throw new Error("The ACP connection is not active");
261
+ return state;
262
+ }
263
+ /** Retain a binding, or release it when the connection closed meanwhile. */
264
+ async function retain(state, binding) {
265
+ if (state.closed) {
266
+ await closeOnce(closedBindings, binding);
267
+ throw new Error("The ACP connection closed");
268
+ }
269
+ state.sessions.set(binding.sessionId, binding);
270
+ }
271
+ async function closeConnection(state) {
272
+ if (state.closed)
273
+ return state.closed;
274
+ state.closed = (async () => {
275
+ const watchOutcomes = await Promise.all([...state.active.values()].map((turn) => detach(turn, closedWatches)));
276
+ const leaseOutcomes = await Promise.all([...state.sessions.values()].map((binding) => closeOnce(closedBindings, binding).then(() => ({ kind: "closed" }), (cause) => ({
277
+ kind: "failed",
278
+ error: asError(cause),
279
+ }))));
280
+ state.active.clear();
281
+ state.sessions.clear();
282
+ const failures = [...watchOutcomes, ...leaseOutcomes]
283
+ .filter((outcome) => outcome.kind === "failed")
284
+ .map((outcome) => outcome.error);
285
+ const firstFailure = failures[0];
286
+ if (failures.length === 1 && firstFailure !== undefined)
287
+ throw firstFailure;
288
+ if (firstFailure !== undefined) {
289
+ throw new AggregateError(failures, "ACP connection cleanup failed", {
290
+ cause: firstFailure,
291
+ });
292
+ }
293
+ })();
294
+ return state.closed;
295
+ }
296
+ app.onConnect(async (connection) => {
297
+ const previous = connectionLease;
298
+ if (previous) {
299
+ if (!previous.cleanup) {
300
+ connection.close(new Error("This ACP AgentApp already has a connection"));
301
+ return;
302
+ }
303
+ previous.accepting = false;
304
+ await previous.cleanup;
305
+ if (connectionLease && connectionLease !== previous) {
306
+ connection.close(new Error("This ACP AgentApp already has a connection"));
307
+ return;
308
+ }
309
+ }
310
+ const state = {
311
+ sessions: new Map(),
312
+ active: new Map(),
313
+ closed: undefined,
314
+ };
315
+ const lease = {
316
+ state,
317
+ cleanup: undefined,
318
+ accepting: true,
319
+ };
320
+ connectionLease = lease;
321
+ let finished;
322
+ const finish = () => {
323
+ if (finished)
324
+ return finished;
325
+ lease.accepting = false;
326
+ finished = closeConnection(state).then(() => {
327
+ if (connectionLease === lease)
328
+ connectionLease = undefined;
329
+ });
330
+ lease.cleanup = finished;
331
+ return finished;
332
+ };
333
+ connection.signal.addEventListener("abort", () => void finish(), {
334
+ once: true,
335
+ });
336
+ void connection.closed.then(finish);
337
+ });
338
+ app.onRequest(acp.methods.agent.initialize, (ctx) => {
339
+ const sessionCapabilities = { close: {} };
340
+ if (directory.list !== undefined)
341
+ sessionCapabilities.list = {};
342
+ return {
343
+ protocolVersion: acp.PROTOCOL_VERSION,
344
+ authMethods: options.terminalAuth !== undefined &&
345
+ ctx.params.clientCapabilities?.auth?.terminal === true
346
+ ? [{ ...options.terminalAuth, type: "terminal" }]
347
+ : [],
348
+ agentCapabilities: {
349
+ loadSession: true,
350
+ sessionCapabilities,
351
+ promptCapabilities: {
352
+ image: true,
353
+ audio: false,
354
+ embeddedContext: true,
355
+ },
356
+ },
357
+ };
358
+ });
359
+ if (directory.list !== undefined) {
360
+ const list = directory.list;
361
+ app.onRequest(acp.methods.agent.session.list, (ctx) => {
362
+ const input = { signal: ctx.signal };
363
+ if (ctx.params.cwd != null)
364
+ input.cwd = ctx.params.cwd;
365
+ if (ctx.params.cursor != null)
366
+ input.cursor = ctx.params.cursor;
367
+ return list(input);
368
+ });
369
+ }
370
+ app.onRequest(acp.methods.agent.session.new, async (ctx) => {
371
+ const state = stateFor();
372
+ const binding = await directory.create({
373
+ cwd: ctx.params.cwd,
374
+ additionalDirectories: ctx.params.additionalDirectories ?? [],
375
+ mcpServers: ctx.params.mcpServers,
376
+ signal: ctx.signal,
377
+ });
378
+ assertSessionId(binding.sessionId);
379
+ if (state.sessions.has(binding.sessionId)) {
380
+ await closeOnce(closedBindings, binding);
381
+ throw new Error("The Session owner returned a duplicate Session identity");
382
+ }
383
+ await retain(state, binding);
384
+ return { sessionId: binding.sessionId };
385
+ });
386
+ app.onRequest(acp.methods.agent.session.load, async (ctx) => {
387
+ const state = stateFor();
388
+ const binding = await directory.load({
389
+ sessionId: ctx.params.sessionId,
390
+ cwd: ctx.params.cwd,
391
+ additionalDirectories: ctx.params.additionalDirectories ?? [],
392
+ mcpServers: ctx.params.mcpServers,
393
+ signal: ctx.signal,
394
+ });
395
+ assertSessionId(binding.sessionId);
396
+ if (binding.sessionId !== ctx.params.sessionId) {
397
+ await closeOnce(closedBindings, binding);
398
+ throw new Error("The Session owner returned a different Session identity");
399
+ }
400
+ const existing = state.sessions.get(binding.sessionId);
401
+ if (existing && state.active.has(binding.sessionId)) {
402
+ if (existing !== binding)
403
+ await closeOnce(closedBindings, binding);
404
+ throw new Error("This ACP Session has an active prompt");
405
+ }
406
+ try {
407
+ const snapshot = await binding.session.read();
408
+ if (snapshot.sessionId !== binding.sessionId)
409
+ throw new Error("The Session returned a different identity");
410
+ for (const message of snapshot.messages)
411
+ await projectMessage(ctx.client, binding.sessionId, message);
412
+ }
413
+ catch (error) {
414
+ await closeOnce(closedBindings, binding);
415
+ throw error;
416
+ }
417
+ if (existing && existing !== binding)
418
+ await closeOnce(closedBindings, existing);
419
+ await retain(state, binding);
420
+ return {};
421
+ });
422
+ app.onRequest(acp.methods.agent.session.prompt, async (ctx) => {
423
+ const state = stateFor();
424
+ const binding = state.sessions.get(ctx.params.sessionId);
425
+ if (!binding)
426
+ throw new Error("The requested Session is not authorized");
427
+ if (state.active.has(binding.sessionId))
428
+ throw new Error("This ACP Session already has an active prompt");
429
+ let finish;
430
+ const done = new Promise((resolve) => {
431
+ finish = resolve;
432
+ });
433
+ const turn = {
434
+ binding,
435
+ commandId: undefined,
436
+ cancelRequested: false,
437
+ detached: false,
438
+ watch: undefined,
439
+ done,
440
+ finish,
441
+ };
442
+ state.active.set(binding.sessionId, turn);
443
+ try {
444
+ const watch = await binding.session.watch();
445
+ turn.watch = watch;
446
+ // Disconnect may have detached the turn while its watch was opening.
447
+ // The finally block closes this late watch without admitting a prompt.
448
+ assertPromptAttached(turn, state);
449
+ if (watch.snapshot.sessionId !== binding.sessionId)
450
+ throw new Error("The Session returned a different identity");
451
+ // Do not use the ACP JSON-RPC ID as Shiitake's idempotency key. The host
452
+ // owns that correlation boundary; this call lets Shiitake generate it.
453
+ const input = promptInput(ctx.params.prompt);
454
+ const accepted = await binding.session.send(input.text, input.images.length === 0 ? undefined : { images: input.images });
455
+ turn.commandId = accepted.commandId;
456
+ if (turn.cancelRequested)
457
+ await binding.session.cancel({ commandId: accepted.commandId });
458
+ const projection = {
459
+ commandId: accepted.commandId,
460
+ streamed: new Map(),
461
+ projected: new Set(),
462
+ };
463
+ for await (const frame of watch.changes) {
464
+ const terminals = await projectFrame(ctx.client, binding.sessionId, frame, projection);
465
+ const terminal = terminals.find((candidate) => candidate.commandId === accepted.commandId);
466
+ if (terminal)
467
+ return { stopReason: stopReason(terminal.outcome) };
468
+ }
469
+ if (turn.detached)
470
+ throw new Error("ACP observation detached before terminal settlement");
471
+ throw new Error("Session observation ended before terminal settlement");
472
+ }
473
+ finally {
474
+ if (state.active.get(binding.sessionId) === turn)
475
+ state.active.delete(binding.sessionId);
476
+ try {
477
+ if (turn.watch !== undefined)
478
+ await closeWatchOnce(closedWatches, turn.watch);
479
+ }
480
+ finally {
481
+ turn.finish();
482
+ }
483
+ }
484
+ });
485
+ app.onRequest(acp.methods.agent.session.close, async (ctx) => {
486
+ const state = stateFor();
487
+ const binding = state.sessions.get(ctx.params.sessionId);
488
+ if (!binding)
489
+ throw new Error("The requested Session is not authorized");
490
+ const turn = state.active.get(binding.sessionId);
491
+ let watchClose = { kind: "closed" };
492
+ if (turn) {
493
+ turn.cancelRequested = true;
494
+ const { commandId } = turn;
495
+ const settled = await settlesWithin((async () => {
496
+ if (commandId !== undefined)
497
+ await binding.session.cancel({ commandId });
498
+ await turn.done;
499
+ })(), closeSettleMs);
500
+ // The owner did not settle the cancel in time: stop observing so the
501
+ // close completes; the prompt request ends as detached.
502
+ if (!settled)
503
+ watchClose = await detach(turn, closedWatches);
504
+ }
505
+ state.sessions.delete(binding.sessionId);
506
+ const leaseClose = await closeOnce(closedBindings, binding).then(() => ({ kind: "closed" }), (cause) => ({ kind: "failed", error: asError(cause) }));
507
+ if (watchClose.kind === "failed") {
508
+ if (leaseClose.kind === "failed") {
509
+ throw new AggregateError([watchClose.error, leaseClose.error], "ACP watch and session lease cleanup failed", { cause: watchClose.error });
510
+ }
511
+ throw watchClose.error;
512
+ }
513
+ if (leaseClose.kind === "failed")
514
+ throw leaseClose.error;
515
+ return {};
516
+ });
517
+ app.onNotification(acp.methods.agent.session.cancel, async (ctx) => {
518
+ const state = stateFor();
519
+ const binding = state.sessions.get(ctx.params.sessionId);
520
+ if (!binding)
521
+ return;
522
+ const turn = state.active.get(binding.sessionId);
523
+ if (!turn)
524
+ return;
525
+ turn.cancelRequested = true;
526
+ if (turn.commandId !== undefined)
527
+ await binding.session.cancel({ commandId: turn.commandId });
528
+ });
529
+ return app;
530
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./adapter.js";
2
+ export * from "./session-directory.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./adapter.js";
2
+ export * from "./session-directory.js";
@@ -0,0 +1,16 @@
1
+ import { type AuthorizedShiitakeSessionDirectory } from "./adapter.js";
2
+ /** Streamable HTTP adapter and its connection cleanup owner. */
3
+ export type ShiitakeAcpServer = Readonly<{
4
+ /** Handle one ACP HTTP request using the supplied authorized directory. */
5
+ handleRequest(request: Request): Promise<Response>;
6
+ /** Close the server and release its connection leases. */
7
+ close(): Promise<void>;
8
+ }>;
9
+ /**
10
+ * Host stable ACP over the maintained Streamable HTTP transport.
11
+ *
12
+ * Each accepted connection gets its own ACP adapter state. The supplied
13
+ * directory remains the authorization boundary and Shiitake remains the
14
+ * Session/transcript owner.
15
+ */
16
+ export declare function createShiitakeAcpServer(directory: AuthorizedShiitakeSessionDirectory): ShiitakeAcpServer;
package/dist/server.js ADDED
@@ -0,0 +1,18 @@
1
+ import { AcpServer } from "@agentclientprotocol/sdk/experimental/server";
2
+ import { createShiitakeAcpAgent, } from "./adapter.js";
3
+ /**
4
+ * Host stable ACP over the maintained Streamable HTTP transport.
5
+ *
6
+ * Each accepted connection gets its own ACP adapter state. The supplied
7
+ * directory remains the authorization boundary and Shiitake remains the
8
+ * Session/transcript owner.
9
+ */
10
+ export function createShiitakeAcpServer(directory) {
11
+ const server = new AcpServer({
12
+ createAgent: () => createShiitakeAcpAgent({ directory }),
13
+ });
14
+ return {
15
+ handleRequest: (request) => server.handleRequest(request),
16
+ close: () => server.close(),
17
+ };
18
+ }
@@ -0,0 +1,27 @@
1
+ import type { McpServer } from "@agentclientprotocol/sdk";
2
+ import type { ShiitakeAgent } from "@fungi.computer/shiitake/client";
3
+ import type { AuthorizedShiitakeSessionDirectory } from "./adapter.js";
4
+ /** Host-authorized Agent client and the release action for its lease. */
5
+ export type ShiitakeClientLease = Readonly<{
6
+ /** Authorized Agent client used to create and load Sessions. */
7
+ client: ShiitakeAgent;
8
+ /** Release the host authorization lease without deleting Sessions. */
9
+ close: () => Promise<void>;
10
+ }>;
11
+ /** Client leasing and context validation supplied by the host. */
12
+ export type ShiitakeSessionDirectoryOptions = Readonly<{
13
+ /** Acquire a fresh authorized client lease for each create or load. */
14
+ openClient: () => Promise<ShiitakeClientLease>;
15
+ /** Reject context outside the host's allowed directories or MCP servers. */
16
+ validateContext?: (input: {
17
+ cwd: string;
18
+ additionalDirectories: readonly string[];
19
+ mcpServers: readonly McpServer[];
20
+ }) => void;
21
+ }>;
22
+ /**
23
+ * Adapt one host-authorized Shiitake client factory to ACP Session ownership.
24
+ * Every create/load gets its own client lease; closing ACP releases that lease
25
+ * without deleting the durable Session.
26
+ */
27
+ export declare function createAuthorizedShiitakeSessionDirectory(options: ShiitakeSessionDirectoryOptions): AuthorizedShiitakeSessionDirectory;
@@ -0,0 +1,74 @@
1
+ import { defineIdFamily } from "@fungi.computer/cairn/id";
2
+ const createRequests = defineIdFamily({
3
+ family: "acp-session-create-request",
4
+ version: 1,
5
+ kinds: { request: { prefix: "request" } },
6
+ });
7
+ function abortError() {
8
+ return new DOMException("The ACP Session operation was cancelled", "AbortError");
9
+ }
10
+ async function abortable(task, signal) {
11
+ if (signal.aborted)
12
+ throw abortError();
13
+ let onAbort;
14
+ const cancelled = new Promise((_, reject) => {
15
+ onAbort = () => {
16
+ reject(abortError());
17
+ };
18
+ signal.addEventListener("abort", onAbort, { once: true });
19
+ });
20
+ return Promise.race([task, cancelled]).finally(() => {
21
+ if (onAbort)
22
+ signal.removeEventListener("abort", onAbort);
23
+ });
24
+ }
25
+ function binding(lease, sessionId) {
26
+ return {
27
+ session: lease.client.session(sessionId),
28
+ sessionId,
29
+ close: lease.close,
30
+ };
31
+ }
32
+ /**
33
+ * Adapt one host-authorized Shiitake client factory to ACP Session ownership.
34
+ * Every create/load gets its own client lease; closing ACP releases that lease
35
+ * without deleting the durable Session.
36
+ */
37
+ export function createAuthorizedShiitakeSessionDirectory(options) {
38
+ const validate = (input) => options.validateContext?.(input);
39
+ return {
40
+ async create(input) {
41
+ validate(input);
42
+ const lease = await options.openClient();
43
+ try {
44
+ if (input.signal.aborted)
45
+ throw abortError();
46
+ // ACP create requests carry no stable retry identity of their own.
47
+ const requestId = createRequests.request.generate();
48
+ const sessionId = await abortable(lease.client.createSession(requestId), input.signal);
49
+ return binding(lease, sessionId);
50
+ }
51
+ catch (error) {
52
+ await lease.close();
53
+ throw error;
54
+ }
55
+ },
56
+ async load(input) {
57
+ validate(input);
58
+ const lease = await options.openClient();
59
+ try {
60
+ if (input.signal.aborted)
61
+ throw abortError();
62
+ const session = lease.client.session(input.sessionId);
63
+ const snapshot = await abortable(session.read(), input.signal);
64
+ if (snapshot.sessionId !== input.sessionId)
65
+ throw new Error("The Agent returned a different Session identity");
66
+ return { session, sessionId: input.sessionId, close: lease.close };
67
+ }
68
+ catch (error) {
69
+ await lease.close();
70
+ throw error;
71
+ }
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,27 @@
1
+ <!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
2
+
3
+ **@fungi.computer/graft**
4
+
5
+ ---
6
+
7
+ # Graft API overview
8
+
9
+ | Import | Export | Purpose |
10
+ | ------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------- |
11
+ | `@fungi.computer/graft` | `createShiitakeAcpAgent` | Project an authorized Shiitake Session through ACP. |
12
+ | `@fungi.computer/graft` | `createAuthorizedShiitakeSessionDirectory` | Adapt a host-authorized client lease factory to Session creation and loading. |
13
+ | `@fungi.computer/graft` | `AuthorizedShiitakeSessionDirectory` | Let the host authorize Session creation and loading. |
14
+ | `@fungi.computer/graft` | `agentAcpMethods` | Expose the maintained ACP agent methods. |
15
+ | `@fungi.computer/graft/server` | `createShiitakeAcpServer` | Serve the same adapter through Streamable HTTP. |
16
+
17
+ The host supplies authorization. Shiitake owns Session state and the durable
18
+ transcript. Close connections to release their client leases and observations;
19
+ closing a lease does not delete the Session. The generated module pages below
20
+ include the option, lease and server types.
21
+
22
+ ## Modules
23
+
24
+ | Module | Description |
25
+ | ------------------- | ----------- |
26
+ | [index](index.md) | - |
27
+ | [server](server.md) | - |
@@ -0,0 +1,136 @@
1
+ <!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
2
+
3
+ [**@fungi.computer/graft**](README.md)
4
+
5
+ ---
6
+
7
+ [@fungi.computer/graft](README.md) / index
8
+
9
+ # index
10
+
11
+ ## Type Aliases
12
+
13
+ ### AuthorizedShiitakeSession
14
+
15
+ ```ts
16
+ type AuthorizedShiitakeSession = Readonly<{
17
+ session: ShiitakeSession;
18
+ sessionId: string;
19
+ close: () => Promise<void>;
20
+ }>;
21
+ ```
22
+
23
+ A Session returned by the host's authorization boundary.
24
+
25
+ ---
26
+
27
+ ### AuthorizedShiitakeSessionDirectory
28
+
29
+ ```ts
30
+ type AuthorizedShiitakeSessionDirectory = Readonly<{
31
+ create: (input) => Promise<AuthorizedShiitakeSession>;
32
+ list?: (input) => Promise<acp.ListSessionsResponse>;
33
+ load: (input) => Promise<AuthorizedShiitakeSession>;
34
+ }>;
35
+ ```
36
+
37
+ Directory owner used by ACP lifecycle callbacks.
38
+
39
+ ---
40
+
41
+ ### ShiitakeAcpAdapterOptions
42
+
43
+ ```ts
44
+ type ShiitakeAcpAdapterOptions = Readonly<{
45
+ directory: AuthorizedShiitakeSessionDirectory;
46
+ name?: string;
47
+ terminalAuth?: acp.AuthMethodTerminal;
48
+ closeSettleMs?: number;
49
+ }>;
50
+ ```
51
+
52
+ Host directory, agent identity and lifecycle options for an ACP adapter.
53
+
54
+ ---
55
+
56
+ ### ShiitakeClientLease
57
+
58
+ ```ts
59
+ type ShiitakeClientLease = Readonly<{
60
+ client: ShiitakeAgent;
61
+ close: () => Promise<void>;
62
+ }>;
63
+ ```
64
+
65
+ Host-authorized Agent client and the release action for its lease.
66
+
67
+ ---
68
+
69
+ ### ShiitakeSessionDirectoryOptions
70
+
71
+ ```ts
72
+ type ShiitakeSessionDirectoryOptions = Readonly<{
73
+ openClient: () => Promise<ShiitakeClientLease>;
74
+ validateContext?: (input) => void;
75
+ }>;
76
+ ```
77
+
78
+ Client leasing and context validation supplied by the host.
79
+
80
+ ## Variables
81
+
82
+ ### agentAcpMethods
83
+
84
+ ```ts
85
+ const agentAcpMethods: object = acp.methods.agent;
86
+ ```
87
+
88
+ Maintained ACP method descriptors advertised by this agent adapter.
89
+
90
+ ## Functions
91
+
92
+ ### createShiitakeAcpAgent()
93
+
94
+ ```ts
95
+ function createShiitakeAcpAgent(options): AgentApp;
96
+ ```
97
+
98
+ Projects ACP stable v1 onto Sessions from one host-authorized directory.
99
+
100
+ The host directory owns Session creation/loading and authorization. ACP request
101
+ IDs are retained for correlation while Shiitake generates its own idempotency
102
+ key and returns its own accepted command ID.
103
+
104
+ #### Parameters
105
+
106
+ | Parameter | Type |
107
+ | --------- | --------------------------------------------------------- |
108
+ | `options` | [`ShiitakeAcpAdapterOptions`](#shiitakeacpadapteroptions) |
109
+
110
+ #### Returns
111
+
112
+ `AgentApp`
113
+
114
+ ---
115
+
116
+ ### createAuthorizedShiitakeSessionDirectory()
117
+
118
+ ```ts
119
+ function createAuthorizedShiitakeSessionDirectory(
120
+ options,
121
+ ): AuthorizedShiitakeSessionDirectory;
122
+ ```
123
+
124
+ Adapt one host-authorized Shiitake client factory to ACP Session ownership.
125
+ Every create/load gets its own client lease; closing ACP releases that lease
126
+ without deleting the durable Session.
127
+
128
+ #### Parameters
129
+
130
+ | Parameter | Type |
131
+ | --------- | --------------------------------------------------------------------- |
132
+ | `options` | [`ShiitakeSessionDirectoryOptions`](#shiitakesessiondirectoryoptions) |
133
+
134
+ #### Returns
135
+
136
+ [`AuthorizedShiitakeSessionDirectory`](#authorizedshiitakesessiondirectory)
@@ -0,0 +1,46 @@
1
+ <!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
2
+
3
+ [**@fungi.computer/graft**](README.md)
4
+
5
+ ---
6
+
7
+ [@fungi.computer/graft](README.md) / server
8
+
9
+ # server
10
+
11
+ ## Type Aliases
12
+
13
+ ### ShiitakeAcpServer
14
+
15
+ ```ts
16
+ type ShiitakeAcpServer = Readonly<{
17
+ handleRequest: Promise<Response>;
18
+ close: Promise<void>;
19
+ }>;
20
+ ```
21
+
22
+ Streamable HTTP adapter and its connection cleanup owner.
23
+
24
+ ## Functions
25
+
26
+ ### createShiitakeAcpServer()
27
+
28
+ ```ts
29
+ function createShiitakeAcpServer(directory): ShiitakeAcpServer;
30
+ ```
31
+
32
+ Host stable ACP over the maintained Streamable HTTP transport.
33
+
34
+ Each accepted connection gets its own ACP adapter state. The supplied directory
35
+ remains the authorization boundary and Shiitake remains the Session/transcript
36
+ owner.
37
+
38
+ #### Parameters
39
+
40
+ | Parameter | Type |
41
+ | ----------- | ----------------------------------------------------------------------------------- |
42
+ | `directory` | [`AuthorizedShiitakeSessionDirectory`](index.md#authorizedshiitakesessiondirectory) |
43
+
44
+ #### Returns
45
+
46
+ [`ShiitakeAcpServer`](#shiitakeacpserver)
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@fungi.computer/graft",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "ACP v1 projection for an authorized Shiitake Session",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./server": {
14
+ "types": "./dist/server.d.ts",
15
+ "import": "./dist/server.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "docs/api",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "dependencies": {
25
+ "@agentclientprotocol/sdk": "1.7.0",
26
+ "@fungi.computer/cairn": "0.0.1",
27
+ "@fungi.computer/mule": "0.0.2",
28
+ "@fungi.computer/shiitake": "0.2.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "24.13.3",
32
+ "typescript": "5.9.3",
33
+ "vitest": "4.1.11"
34
+ },
35
+ "botanical": {
36
+ "role": "package",
37
+ "operations": {
38
+ "lint": "required",
39
+ "typecheck": "required",
40
+ "build": "required",
41
+ "test": "required",
42
+ "docs": "required",
43
+ "pack": "required",
44
+ "smoke": "required",
45
+ "stranger": "required"
46
+ }
47
+ },
48
+ "license": "MIT",
49
+ "author": "Levi <levi@technomancy.dev>",
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/fungi-computer/graft.git"
53
+ },
54
+ "scripts": {
55
+ "lint": "eslint src test --max-warnings=0 && ../../node_modules/.bin/oxlint --config ../../oxlint.config.ts --deny-warnings src test",
56
+ "typecheck": "tsc -p tsconfig.json --noEmit",
57
+ "build": "pnpm run clean && tsc -p tsconfig.build.json",
58
+ "clean": "node --input-type=module --eval \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\"",
59
+ "test": "vitest run",
60
+ "format:check": "node ../../tooling/repository/scripts/format-package-check.mjs",
61
+ "lint:anti-slop": "node ../../tooling/repository/scripts/lint-package-anti-slop.mjs",
62
+ "docs": "tsx ../../tooling/repository/src/docs/cli.ts check --root ../..",
63
+ "docs:write": "tsx ../../tooling/repository/src/docs/cli.ts write --root ../..",
64
+ "pack": "mkdir -p ../../.botanical/graft-pack && pnpm pack --pack-destination ../../.botanical/graft-pack",
65
+ "smoke": "node test/stranger/runtime.mjs",
66
+ "stranger": "tsx ../../tooling/repository/src/artifacts/cli.ts --repo-root ../.. ."
67
+ }
6
68
  }