@fungi.computer/graft 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/dist/adapter.d.ts +95 -0
- package/dist/adapter.js +533 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +18 -0
- package/dist/session-directory.d.ts +27 -0
- package/dist/session-directory.js +74 -0
- package/docs/api/README.md +27 -0
- package/docs/api/index.md +136 -0
- package/docs/api/server.md +46 -0
- package/package.json +65 -3
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
|
-
#
|
|
1
|
+
# Graft (`@fungi.computer/graft`)
|
|
2
2
|
|
|
3
|
-
This
|
|
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;
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
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
|
+
// ACP continues to expose durable tool results, not transient output.
|
|
134
|
+
if (frame.kind === "tool_output")
|
|
135
|
+
return [];
|
|
136
|
+
if (frame.kind === "progress") {
|
|
137
|
+
await projectProgress(client, sessionId, frame.progress, turn);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
if (frame.kind === "reset") {
|
|
141
|
+
// A reset replaces the baseline mid-turn. Project this turn's messages
|
|
142
|
+
// that were not already sent; everything else is history. Later progress
|
|
143
|
+
// frames carry cumulative text, so live progress resumes from its delta.
|
|
144
|
+
await projectTurnMessages(client, sessionId, frame.baseline.messages, turn);
|
|
145
|
+
return frame.baseline.terminals;
|
|
146
|
+
}
|
|
147
|
+
await projectTurnMessages(client, sessionId, frame.messages, turn);
|
|
148
|
+
return frame.terminals;
|
|
149
|
+
}
|
|
150
|
+
function stopReason(outcome) {
|
|
151
|
+
if (outcome === "completed")
|
|
152
|
+
return "end_turn";
|
|
153
|
+
if (outcome === "cancelled")
|
|
154
|
+
return "cancelled";
|
|
155
|
+
throw new Error(`Session turn ended with ${outcome}`);
|
|
156
|
+
}
|
|
157
|
+
function assertSessionId(sessionId) {
|
|
158
|
+
if (sessionId.length === 0)
|
|
159
|
+
throw new Error("The Session owner returned an empty identity");
|
|
160
|
+
}
|
|
161
|
+
function promptInput(prompt) {
|
|
162
|
+
const text = [];
|
|
163
|
+
const images = [];
|
|
164
|
+
for (const block of prompt) {
|
|
165
|
+
switch (block.type) {
|
|
166
|
+
case "text":
|
|
167
|
+
text.push(block.text);
|
|
168
|
+
break;
|
|
169
|
+
case "image":
|
|
170
|
+
images.push({
|
|
171
|
+
type: "image",
|
|
172
|
+
data: block.data,
|
|
173
|
+
mimeType: block.mimeType,
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
case "resource_link":
|
|
177
|
+
text.push(`\nReferenced resource ${JSON.stringify(block.name)}: ${JSON.stringify(block.uri)}\n`);
|
|
178
|
+
break;
|
|
179
|
+
case "resource": {
|
|
180
|
+
const resource = block.resource;
|
|
181
|
+
text.push(`\nResource ${JSON.stringify(resource.uri)} (${resource.mimeType ?? "unspecified"}):\n`);
|
|
182
|
+
if ("text" in resource)
|
|
183
|
+
text.push(resource.text);
|
|
184
|
+
else if (resource.mimeType?.startsWith("image/") === true)
|
|
185
|
+
images.push({
|
|
186
|
+
type: "image",
|
|
187
|
+
data: resource.blob,
|
|
188
|
+
mimeType: resource.mimeType,
|
|
189
|
+
});
|
|
190
|
+
else
|
|
191
|
+
throw acp.RequestError.invalidParams(undefined, "Binary resources require a supported image MIME type; send other context as text");
|
|
192
|
+
text.push("\nEnd resource\n");
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case "audio":
|
|
196
|
+
throw acp.RequestError.invalidParams(undefined, "Audio prompts are unsupported");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { text: text.join(""), images };
|
|
200
|
+
}
|
|
201
|
+
/** Resolve true when `work` settles within `ms`, false when the bound passes. */
|
|
202
|
+
function settlesWithin(work, ms) {
|
|
203
|
+
let timer;
|
|
204
|
+
return Promise.race([
|
|
205
|
+
work.then(() => true),
|
|
206
|
+
new Promise((resolve) => {
|
|
207
|
+
timer = setTimeout(() => {
|
|
208
|
+
resolve(false);
|
|
209
|
+
}, ms);
|
|
210
|
+
}),
|
|
211
|
+
]).finally(() => {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function asError(cause) {
|
|
216
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
217
|
+
}
|
|
218
|
+
function closeWatchOnce(closed, watch) {
|
|
219
|
+
const existing = closed.get(watch);
|
|
220
|
+
if (existing)
|
|
221
|
+
return existing;
|
|
222
|
+
const closing = Promise.resolve().then(() => watch.close());
|
|
223
|
+
closed.set(watch, closing);
|
|
224
|
+
return closing;
|
|
225
|
+
}
|
|
226
|
+
/** Stop observing a turn; its prompt request then ends as detached. */
|
|
227
|
+
function detach(turn, closedWatches) {
|
|
228
|
+
turn.detached = true;
|
|
229
|
+
if (turn.watch === undefined)
|
|
230
|
+
return Promise.resolve({ kind: "closed" });
|
|
231
|
+
return closeWatchOnce(closedWatches, turn.watch).then(() => ({ kind: "closed" }), (cause) => ({ kind: "failed", error: asError(cause) }));
|
|
232
|
+
}
|
|
233
|
+
/** Re-read teardown state after an asynchronous owner operation. */
|
|
234
|
+
function assertPromptAttached(turn, state) {
|
|
235
|
+
if (turn.detached || state.closed !== undefined)
|
|
236
|
+
throw new Error("The ACP connection closed before prompt admission");
|
|
237
|
+
}
|
|
238
|
+
function closeOnce(closed, binding) {
|
|
239
|
+
const existing = closed.get(binding);
|
|
240
|
+
if (existing)
|
|
241
|
+
return existing;
|
|
242
|
+
const closing = Promise.resolve().then(() => binding.close());
|
|
243
|
+
closed.set(binding, closing);
|
|
244
|
+
return closing;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Projects ACP stable v1 onto Sessions from one host-authorized directory.
|
|
248
|
+
*
|
|
249
|
+
* The host directory owns Session creation/loading and authorization. ACP
|
|
250
|
+
* request IDs are retained for correlation while Shiitake generates its own
|
|
251
|
+
* idempotency key and returns its own accepted command ID.
|
|
252
|
+
*/
|
|
253
|
+
export function createShiitakeAcpAgent(options) {
|
|
254
|
+
const { directory } = options;
|
|
255
|
+
const closeSettleMs = options.closeSettleMs ?? DEFAULT_CLOSE_SETTLE_MS;
|
|
256
|
+
const closedBindings = new WeakMap();
|
|
257
|
+
const closedWatches = new WeakMap();
|
|
258
|
+
const app = acp.agent({ name: options.name ?? "Shiitake" });
|
|
259
|
+
let connectionLease;
|
|
260
|
+
function stateFor() {
|
|
261
|
+
const state = connectionLease?.state;
|
|
262
|
+
if (!state || !connectionLease?.accepting)
|
|
263
|
+
throw new Error("The ACP connection is not active");
|
|
264
|
+
return state;
|
|
265
|
+
}
|
|
266
|
+
/** Retain a binding, or release it when the connection closed meanwhile. */
|
|
267
|
+
async function retain(state, binding) {
|
|
268
|
+
if (state.closed) {
|
|
269
|
+
await closeOnce(closedBindings, binding);
|
|
270
|
+
throw new Error("The ACP connection closed");
|
|
271
|
+
}
|
|
272
|
+
state.sessions.set(binding.sessionId, binding);
|
|
273
|
+
}
|
|
274
|
+
async function closeConnection(state) {
|
|
275
|
+
if (state.closed)
|
|
276
|
+
return state.closed;
|
|
277
|
+
state.closed = (async () => {
|
|
278
|
+
const watchOutcomes = await Promise.all([...state.active.values()].map((turn) => detach(turn, closedWatches)));
|
|
279
|
+
const leaseOutcomes = await Promise.all([...state.sessions.values()].map((binding) => closeOnce(closedBindings, binding).then(() => ({ kind: "closed" }), (cause) => ({
|
|
280
|
+
kind: "failed",
|
|
281
|
+
error: asError(cause),
|
|
282
|
+
}))));
|
|
283
|
+
state.active.clear();
|
|
284
|
+
state.sessions.clear();
|
|
285
|
+
const failures = [...watchOutcomes, ...leaseOutcomes]
|
|
286
|
+
.filter((outcome) => outcome.kind === "failed")
|
|
287
|
+
.map((outcome) => outcome.error);
|
|
288
|
+
const firstFailure = failures[0];
|
|
289
|
+
if (failures.length === 1 && firstFailure !== undefined)
|
|
290
|
+
throw firstFailure;
|
|
291
|
+
if (firstFailure !== undefined) {
|
|
292
|
+
throw new AggregateError(failures, "ACP connection cleanup failed", {
|
|
293
|
+
cause: firstFailure,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
})();
|
|
297
|
+
return state.closed;
|
|
298
|
+
}
|
|
299
|
+
app.onConnect(async (connection) => {
|
|
300
|
+
const previous = connectionLease;
|
|
301
|
+
if (previous) {
|
|
302
|
+
if (!previous.cleanup) {
|
|
303
|
+
connection.close(new Error("This ACP AgentApp already has a connection"));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
previous.accepting = false;
|
|
307
|
+
await previous.cleanup;
|
|
308
|
+
if (connectionLease && connectionLease !== previous) {
|
|
309
|
+
connection.close(new Error("This ACP AgentApp already has a connection"));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const state = {
|
|
314
|
+
sessions: new Map(),
|
|
315
|
+
active: new Map(),
|
|
316
|
+
closed: undefined,
|
|
317
|
+
};
|
|
318
|
+
const lease = {
|
|
319
|
+
state,
|
|
320
|
+
cleanup: undefined,
|
|
321
|
+
accepting: true,
|
|
322
|
+
};
|
|
323
|
+
connectionLease = lease;
|
|
324
|
+
let finished;
|
|
325
|
+
const finish = () => {
|
|
326
|
+
if (finished)
|
|
327
|
+
return finished;
|
|
328
|
+
lease.accepting = false;
|
|
329
|
+
finished = closeConnection(state).then(() => {
|
|
330
|
+
if (connectionLease === lease)
|
|
331
|
+
connectionLease = undefined;
|
|
332
|
+
});
|
|
333
|
+
lease.cleanup = finished;
|
|
334
|
+
return finished;
|
|
335
|
+
};
|
|
336
|
+
connection.signal.addEventListener("abort", () => void finish(), {
|
|
337
|
+
once: true,
|
|
338
|
+
});
|
|
339
|
+
void connection.closed.then(finish);
|
|
340
|
+
});
|
|
341
|
+
app.onRequest(acp.methods.agent.initialize, (ctx) => {
|
|
342
|
+
const sessionCapabilities = { close: {} };
|
|
343
|
+
if (directory.list !== undefined)
|
|
344
|
+
sessionCapabilities.list = {};
|
|
345
|
+
return {
|
|
346
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
347
|
+
authMethods: options.terminalAuth !== undefined &&
|
|
348
|
+
ctx.params.clientCapabilities?.auth?.terminal === true
|
|
349
|
+
? [{ ...options.terminalAuth, type: "terminal" }]
|
|
350
|
+
: [],
|
|
351
|
+
agentCapabilities: {
|
|
352
|
+
loadSession: true,
|
|
353
|
+
sessionCapabilities,
|
|
354
|
+
promptCapabilities: {
|
|
355
|
+
image: true,
|
|
356
|
+
audio: false,
|
|
357
|
+
embeddedContext: true,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
if (directory.list !== undefined) {
|
|
363
|
+
const list = directory.list;
|
|
364
|
+
app.onRequest(acp.methods.agent.session.list, (ctx) => {
|
|
365
|
+
const input = { signal: ctx.signal };
|
|
366
|
+
if (ctx.params.cwd != null)
|
|
367
|
+
input.cwd = ctx.params.cwd;
|
|
368
|
+
if (ctx.params.cursor != null)
|
|
369
|
+
input.cursor = ctx.params.cursor;
|
|
370
|
+
return list(input);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
app.onRequest(acp.methods.agent.session.new, async (ctx) => {
|
|
374
|
+
const state = stateFor();
|
|
375
|
+
const binding = await directory.create({
|
|
376
|
+
cwd: ctx.params.cwd,
|
|
377
|
+
additionalDirectories: ctx.params.additionalDirectories ?? [],
|
|
378
|
+
mcpServers: ctx.params.mcpServers,
|
|
379
|
+
signal: ctx.signal,
|
|
380
|
+
});
|
|
381
|
+
assertSessionId(binding.sessionId);
|
|
382
|
+
if (state.sessions.has(binding.sessionId)) {
|
|
383
|
+
await closeOnce(closedBindings, binding);
|
|
384
|
+
throw new Error("The Session owner returned a duplicate Session identity");
|
|
385
|
+
}
|
|
386
|
+
await retain(state, binding);
|
|
387
|
+
return { sessionId: binding.sessionId };
|
|
388
|
+
});
|
|
389
|
+
app.onRequest(acp.methods.agent.session.load, async (ctx) => {
|
|
390
|
+
const state = stateFor();
|
|
391
|
+
const binding = await directory.load({
|
|
392
|
+
sessionId: ctx.params.sessionId,
|
|
393
|
+
cwd: ctx.params.cwd,
|
|
394
|
+
additionalDirectories: ctx.params.additionalDirectories ?? [],
|
|
395
|
+
mcpServers: ctx.params.mcpServers,
|
|
396
|
+
signal: ctx.signal,
|
|
397
|
+
});
|
|
398
|
+
assertSessionId(binding.sessionId);
|
|
399
|
+
if (binding.sessionId !== ctx.params.sessionId) {
|
|
400
|
+
await closeOnce(closedBindings, binding);
|
|
401
|
+
throw new Error("The Session owner returned a different Session identity");
|
|
402
|
+
}
|
|
403
|
+
const existing = state.sessions.get(binding.sessionId);
|
|
404
|
+
if (existing && state.active.has(binding.sessionId)) {
|
|
405
|
+
if (existing !== binding)
|
|
406
|
+
await closeOnce(closedBindings, binding);
|
|
407
|
+
throw new Error("This ACP Session has an active prompt");
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const snapshot = await binding.session.read();
|
|
411
|
+
if (snapshot.sessionId !== binding.sessionId)
|
|
412
|
+
throw new Error("The Session returned a different identity");
|
|
413
|
+
for (const message of snapshot.messages)
|
|
414
|
+
await projectMessage(ctx.client, binding.sessionId, message);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
await closeOnce(closedBindings, binding);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
if (existing && existing !== binding)
|
|
421
|
+
await closeOnce(closedBindings, existing);
|
|
422
|
+
await retain(state, binding);
|
|
423
|
+
return {};
|
|
424
|
+
});
|
|
425
|
+
app.onRequest(acp.methods.agent.session.prompt, async (ctx) => {
|
|
426
|
+
const state = stateFor();
|
|
427
|
+
const binding = state.sessions.get(ctx.params.sessionId);
|
|
428
|
+
if (!binding)
|
|
429
|
+
throw new Error("The requested Session is not authorized");
|
|
430
|
+
if (state.active.has(binding.sessionId))
|
|
431
|
+
throw new Error("This ACP Session already has an active prompt");
|
|
432
|
+
let finish;
|
|
433
|
+
const done = new Promise((resolve) => {
|
|
434
|
+
finish = resolve;
|
|
435
|
+
});
|
|
436
|
+
const turn = {
|
|
437
|
+
binding,
|
|
438
|
+
commandId: undefined,
|
|
439
|
+
cancelRequested: false,
|
|
440
|
+
detached: false,
|
|
441
|
+
watch: undefined,
|
|
442
|
+
done,
|
|
443
|
+
finish,
|
|
444
|
+
};
|
|
445
|
+
state.active.set(binding.sessionId, turn);
|
|
446
|
+
try {
|
|
447
|
+
const watch = await binding.session.watch();
|
|
448
|
+
turn.watch = watch;
|
|
449
|
+
// Disconnect may have detached the turn while its watch was opening.
|
|
450
|
+
// The finally block closes this late watch without admitting a prompt.
|
|
451
|
+
assertPromptAttached(turn, state);
|
|
452
|
+
if (watch.snapshot.sessionId !== binding.sessionId)
|
|
453
|
+
throw new Error("The Session returned a different identity");
|
|
454
|
+
// Do not use the ACP JSON-RPC ID as Shiitake's idempotency key. The host
|
|
455
|
+
// owns that correlation boundary; this call lets Shiitake generate it.
|
|
456
|
+
const input = promptInput(ctx.params.prompt);
|
|
457
|
+
const accepted = await binding.session.send(input.text, input.images.length === 0 ? undefined : { images: input.images });
|
|
458
|
+
turn.commandId = accepted.commandId;
|
|
459
|
+
if (turn.cancelRequested)
|
|
460
|
+
await binding.session.cancel({ commandId: accepted.commandId });
|
|
461
|
+
const projection = {
|
|
462
|
+
commandId: accepted.commandId,
|
|
463
|
+
streamed: new Map(),
|
|
464
|
+
projected: new Set(),
|
|
465
|
+
};
|
|
466
|
+
for await (const frame of watch.changes) {
|
|
467
|
+
const terminals = await projectFrame(ctx.client, binding.sessionId, frame, projection);
|
|
468
|
+
const terminal = terminals.find((candidate) => candidate.commandId === accepted.commandId);
|
|
469
|
+
if (terminal)
|
|
470
|
+
return { stopReason: stopReason(terminal.outcome) };
|
|
471
|
+
}
|
|
472
|
+
if (turn.detached)
|
|
473
|
+
throw new Error("ACP observation detached before terminal settlement");
|
|
474
|
+
throw new Error("Session observation ended before terminal settlement");
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
if (state.active.get(binding.sessionId) === turn)
|
|
478
|
+
state.active.delete(binding.sessionId);
|
|
479
|
+
try {
|
|
480
|
+
if (turn.watch !== undefined)
|
|
481
|
+
await closeWatchOnce(closedWatches, turn.watch);
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
turn.finish();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
app.onRequest(acp.methods.agent.session.close, async (ctx) => {
|
|
489
|
+
const state = stateFor();
|
|
490
|
+
const binding = state.sessions.get(ctx.params.sessionId);
|
|
491
|
+
if (!binding)
|
|
492
|
+
throw new Error("The requested Session is not authorized");
|
|
493
|
+
const turn = state.active.get(binding.sessionId);
|
|
494
|
+
let watchClose = { kind: "closed" };
|
|
495
|
+
if (turn) {
|
|
496
|
+
turn.cancelRequested = true;
|
|
497
|
+
const { commandId } = turn;
|
|
498
|
+
const settled = await settlesWithin((async () => {
|
|
499
|
+
if (commandId !== undefined)
|
|
500
|
+
await binding.session.cancel({ commandId });
|
|
501
|
+
await turn.done;
|
|
502
|
+
})(), closeSettleMs);
|
|
503
|
+
// The owner did not settle the cancel in time: stop observing so the
|
|
504
|
+
// close completes; the prompt request ends as detached.
|
|
505
|
+
if (!settled)
|
|
506
|
+
watchClose = await detach(turn, closedWatches);
|
|
507
|
+
}
|
|
508
|
+
state.sessions.delete(binding.sessionId);
|
|
509
|
+
const leaseClose = await closeOnce(closedBindings, binding).then(() => ({ kind: "closed" }), (cause) => ({ kind: "failed", error: asError(cause) }));
|
|
510
|
+
if (watchClose.kind === "failed") {
|
|
511
|
+
if (leaseClose.kind === "failed") {
|
|
512
|
+
throw new AggregateError([watchClose.error, leaseClose.error], "ACP watch and session lease cleanup failed", { cause: watchClose.error });
|
|
513
|
+
}
|
|
514
|
+
throw watchClose.error;
|
|
515
|
+
}
|
|
516
|
+
if (leaseClose.kind === "failed")
|
|
517
|
+
throw leaseClose.error;
|
|
518
|
+
return {};
|
|
519
|
+
});
|
|
520
|
+
app.onNotification(acp.methods.agent.session.cancel, async (ctx) => {
|
|
521
|
+
const state = stateFor();
|
|
522
|
+
const binding = state.sessions.get(ctx.params.sessionId);
|
|
523
|
+
if (!binding)
|
|
524
|
+
return;
|
|
525
|
+
const turn = state.active.get(binding.sessionId);
|
|
526
|
+
if (!turn)
|
|
527
|
+
return;
|
|
528
|
+
turn.cancelRequested = true;
|
|
529
|
+
if (turn.commandId !== undefined)
|
|
530
|
+
await binding.session.cancel({ commandId: turn.commandId });
|
|
531
|
+
});
|
|
532
|
+
return app;
|
|
533
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/server.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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.1.0",
|
|
27
|
+
"@fungi.computer/mule": "0.1.0",
|
|
28
|
+
"@fungi.computer/shiitake": "0.3.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
|
}
|