pi-ahp 0.1.0 โ 0.1.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/README.md +105 -2
- package/dist/channels/session.js +4 -5
- package/dist/core/client-workarounds.js +168 -52
- package/dist/core/connection.js +1 -1
- package/dist/core/host.js +21 -7
- package/dist/core/sequencer.js +2 -3
- package/dist/core/uri.js +3 -9
- package/dist/host/pi-host.js +11 -11
- package/dist/host/terminal-service.js +6 -1
- package/dist/pi/activity.js +0 -1
- package/dist/pi/chat-driver.js +102 -36
- package/dist/pi/completions.js +1 -4
- package/dist/pi/delete-session.js +23 -11
- package/dist/pi/in-process-backend.js +12 -13
- package/dist/pi/message-input.js +6 -0
- package/dist/pi/models.js +34 -23
- package/dist/pi/project-trust.js +11 -12
- package/dist/pi/provider.js +0 -7
- package/dist/pi/resource-watch.js +8 -0
- package/dist/pi/session-catalogue.js +14 -30
- package/dist/pi/session-config.js +10 -12
- package/dist/pi/session-hydrator.js +25 -9
- package/dist/pi/session-registry.js +144 -40
- package/dist/pi/session-title.js +15 -0
- package/dist/transport/websocket.js +2 -2
- package/dist/tunnel/devtunnel.js +2 -2
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,10 +1,113 @@
|
|
|
1
1
|
# pi-ahp
|
|
2
2
|
|
|
3
|
+
[](https://github.com/Qusic/pi-ahp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/pi-ahp)
|
|
5
|
+
[](https://github.com/earendil-works/pi)
|
|
6
|
+
[](https://github.com/microsoft/agent-host-protocol)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
3
9
|
`pi-ahp` is an [Agent Host Protocol](https://github.com/microsoft/agent-host-protocol) host for the [pi coding agent](https://github.com/earendil-works/pi).
|
|
4
10
|
|
|
5
|
-
It embeds pi and makes its sessions available to AHP clients over WebSocket. Interoperability
|
|
11
|
+
It embeds pi and makes its sessions available to AHP clients over WebSocket. Interoperability is primarily tested with [Agent Host support in Visual Studio Code](https://code.visualstudio.com/docs/agents/concepts/agent-host) and [Agent Console for iPhone and iPad](https://qusic.github.io/agent-console/), but the host is not limited to either client.
|
|
12
|
+
|
|
13
|
+
The project is under active development; expect rough edges and occasional instability. Contributions are welcome.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node.js 24 or later
|
|
18
|
+
- At least one model provider configured for pi
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install --global pi-ahp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`pi-ahp` starts a direct WebSocket listener and prints its connection URL:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pi-ahp
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
On first run, it creates `~/.pi/ahp/settings.json` with a free port and a random token, for example:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"host": "127.0.0.1",
|
|
37
|
+
"port": 32145,
|
|
38
|
+
"token": "<generated token>"
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The token is optional and may be set to `null`. The host and port can also be overridden for one run:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pi-ahp --host 127.0.0.1 --port 31546
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Enter the printed URL in any AHP client. The direct listener is bring-your-own-network: reach it through Tailscale, SSH port forwarding, a reverse proxy, or any other network you manage. Keep the connection token enabled whenever the listener is reachable beyond localhost.
|
|
49
|
+
|
|
50
|
+
`pi-ahp-tunnel` instead creates or reuses a Microsoft Dev Tunnel for clients that support it, including VS Code and Agent Console. It requires the [`devtunnel` CLI](https://aka.ms/devtunnels/download) and does not use the direct-listener settings:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
devtunnel user login
|
|
54
|
+
pi-ahp-tunnel
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run either command with `--help` for all available options.
|
|
58
|
+
|
|
59
|
+
### Visual Studio Code
|
|
60
|
+
|
|
61
|
+
VS Code exposes remote Agent Host setup only in its dedicated Agents window, not in a regular editor window. From a regular window, run **Chat: Open Agents Window**.
|
|
62
|
+
|
|
63
|
+
If the remote-host commands are unavailable, or if you want to use a direct connection without signing in to GitHub, add these settings to VS Code's user `settings.json` before opening the Agents window:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"chat.remoteAgentHostsEnabled": true,
|
|
68
|
+
"chat.agentHost.allowSignedOutWhenUsable": true
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The signed-out option is currently experimental and desktop-only. When prompted, choose **Continue Without Signing In**; you do not need to preconfigure a host manually.
|
|
73
|
+
|
|
74
|
+
- **Direct WebSocket:** run **Agents: Add Remote Agent Host...** and paste the URL printed by `pi-ahp`.
|
|
75
|
+
- **Dev Tunnel:** sign in to the same account in VS Code, then run **Agents: Connect to Remote Agent Host via Dev Tunnel**. Tunnel discovery requires sign-in.
|
|
76
|
+
|
|
77
|
+
## AHP support
|
|
78
|
+
|
|
79
|
+
The following is a high-level mapping to the [official AHP specification](https://microsoft.github.io/agent-host-protocol/specification/overview).
|
|
80
|
+
|
|
81
|
+
Currently supported protocol versions: **0.9.0**
|
|
82
|
+
|
|
83
|
+
๐ข Supported ยท ๐ก Planned ยท ๐ด Out of scope
|
|
6
84
|
|
|
7
|
-
|
|
85
|
+
| Feature | Status | Notes |
|
|
86
|
+
| --- | :---: | --- |
|
|
87
|
+
| Connection lifecycle and reconnect | ๐ข | Active work survives a temporary connection loss, but not a host restart |
|
|
88
|
+
| Durable sessions and history | ๐ข | Completed history for the active conversation branch recovers after restart |
|
|
89
|
+
| Streaming chat and pi tool calls | ๐ข | One chat per session |
|
|
90
|
+
| Steering, queues, drafts, and truncation | ๐ข | Drafts and unconsumed queues do not survive a host restart |
|
|
91
|
+
| Models, session setup, and completions | ๐ข | Model and thinking-level selection, working directory, and `@` file completions |
|
|
92
|
+
| Text attachments | ๐ข | Client-provided text, local file references, and embedded UTF-8 text |
|
|
93
|
+
| Files and watches | ๐ข | Host-local `file:` resources only |
|
|
94
|
+
| Interactive terminals | ๐ข | Client-owned; terminals and scrollback end when the host stops |
|
|
95
|
+
| Images and vision | ๐ก | Pass AHP image attachments to vision-capable pi models |
|
|
96
|
+
| Tool catalogue and client tools | ๐ก | Show available pi tools and let connected clients contribute tools |
|
|
97
|
+
| pi customizations | ๐ก | Show and configure loaded extensions, skills, and prompt templates |
|
|
98
|
+
| Changeset views | ๐ก | Expose uncommitted workspace changes |
|
|
99
|
+
| Terminal command detection | ๐ก | Group terminal output by command and exit status |
|
|
100
|
+
| Persistent read and archive controls | ๐ด | pi sessions do not store this metadata |
|
|
101
|
+
| Multiple chats or working directories | ๐ด | A pi session has one active branch and cwd; branches are not simultaneous AHP chats |
|
|
102
|
+
| Custom agents | ๐ด | pi sub-agents are extension tools rather than selectable agents |
|
|
103
|
+
| Elicitation | ๐ด | pi extension dialogs are not persisted as turn input |
|
|
104
|
+
| Tool input and result confirmation | ๐ด | pi has no built-in permission policy |
|
|
105
|
+
| Errored-turn resume | ๐ด | pi cannot reopen a finalized turn |
|
|
106
|
+
| MCP lifecycle and Apps | ๐ด | pi has no built-in MCP runtime; extension implementations are opaque |
|
|
107
|
+
| Annotations | ๐ด | There is no corresponding pi session state |
|
|
108
|
+
| Automations and automation runs | ๐ด | pi has no scheduler or automation-run model |
|
|
109
|
+
| AHP protected-resource authentication | ๐ด | pi manages model-provider credentials itself |
|
|
110
|
+
| OTLP telemetry channels | ๐ด | pi does not provide host telemetry as OTLP |
|
|
8
111
|
|
|
9
112
|
## License
|
|
10
113
|
|
package/dist/channels/session.js
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A session is the coordination scope; the conversation itself lives on a chat
|
|
5
5
|
* channel underneath it. Creation is asynchronous by design: the host returns
|
|
6
|
-
* immediately with `lifecycle: 'creating'` and dispatches `session/ready`
|
|
7
|
-
* `session/creationFailed`
|
|
8
|
-
* blocks the client's round-trip.
|
|
9
|
-
*
|
|
6
|
+
* immediately with `lifecycle: 'creating'` and dispatches `session/ready` or
|
|
7
|
+
* `session/creationFailed` once backend startup settles, so a slow agent start
|
|
8
|
+
* never blocks the client's round-trip.
|
|
10
9
|
*
|
|
11
10
|
* @see https://microsoft.github.io/agent-host-protocol/specification/session-channel
|
|
12
11
|
*/
|
|
@@ -23,7 +22,7 @@ export function initialSessionState(provider, title, workingDirectory) {
|
|
|
23
22
|
chats: [],
|
|
24
23
|
};
|
|
25
24
|
}
|
|
26
|
-
/**
|
|
25
|
+
/** The low five bits encode activity; higher bits carry independent metadata flags. */
|
|
27
26
|
const STATUS_ACTIVITY_MASK = (1 << 5) - 1;
|
|
28
27
|
function timestamp(summary) {
|
|
29
28
|
const value = Date.parse(summary.modifiedAt);
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Repairs to the traffic of clients whose reading of the protocol differs from
|
|
3
3
|
* this host's. Each entry says what the client does and what would let it go.
|
|
4
4
|
*/
|
|
5
|
-
import { ActionType } from "@microsoft/agent-host-protocol";
|
|
6
|
-
import {
|
|
5
|
+
import { ActionType, JsonRpcErrorCodes } from "@microsoft/agent-host-protocol";
|
|
6
|
+
import { ProtocolError } from "../protocol/errors.js";
|
|
7
|
+
import { chatIdFromUri, chatUri, ROOT_CHANNEL, sessionIdFromUri, sessionUri } from "./channels.js";
|
|
7
8
|
/**
|
|
8
9
|
* Field names that carry session or chat URIs in state, actions, and catalogue
|
|
9
10
|
* notifications. The rewrite itself ignores every other URI scheme.
|
|
@@ -13,11 +14,16 @@ const REWRITABLE_FIELDS = new Set(["channel", "resource", "defaultChat", "sessio
|
|
|
13
14
|
const DERIVED_CHAT_URI = /^ahp-chat:\/\/[^/]+\/([^/?#]+)$/;
|
|
14
15
|
/** The client names VS Code identifies itself with at `initialize`. */
|
|
15
16
|
const VSCODE_CLIENT_NAMES = new Set(["vscode-editor-window", "vscode-agents-window"]);
|
|
17
|
+
const VSCODE_MATERIALIZED_SESSION_DISPOSAL_REFUSAL = "Materialized session disposal is temporarily disabled for VS Code because of a VS Code provisional-session lifecycle bug; this session was kept.";
|
|
18
|
+
/** Actions after which a session is no longer an abandoned empty draft. */
|
|
19
|
+
const MATERIALIZING_ACTIONS = new Set([
|
|
20
|
+
ActionType.ChatTurnStarted,
|
|
21
|
+
ActionType.ChatPendingMessageSet,
|
|
22
|
+
ActionType.SessionTitleChanged,
|
|
23
|
+
]);
|
|
16
24
|
/**
|
|
17
|
-
* The scheme
|
|
18
|
-
*
|
|
19
|
-
* everything about this file is temporary, and a parameter for it would outlive
|
|
20
|
-
* the reason for it in three signatures.
|
|
25
|
+
* The provider scheme affected by this compatibility layer. Keep it local so
|
|
26
|
+
* client-specific routing does not leak into canonical host APIs.
|
|
21
27
|
*/
|
|
22
28
|
const PROVIDER_SESSION_SCHEME = "pi";
|
|
23
29
|
/** Methods where a direct `pi:/...` target can only mean a session. */
|
|
@@ -35,62 +41,157 @@ function providerSessionId(uri) {
|
|
|
35
41
|
function isProviderSession(uri) {
|
|
36
42
|
return uri.toLowerCase().startsWith(`${PROVIDER_SESSION_SCHEME}:/`) && providerSessionId(uri) !== undefined;
|
|
37
43
|
}
|
|
44
|
+
function canonicalSessionUri(uri) {
|
|
45
|
+
const sessionId = providerSessionId(uri);
|
|
46
|
+
return sessionId ? sessionUri(sessionId) : undefined;
|
|
47
|
+
}
|
|
48
|
+
function owningSessionUri(uri) {
|
|
49
|
+
const session = canonicalSessionUri(uri);
|
|
50
|
+
if (session)
|
|
51
|
+
return session;
|
|
52
|
+
const chatId = chatIdFromUri(uri);
|
|
53
|
+
return chatId ? sessionUri(chatId) : undefined;
|
|
54
|
+
}
|
|
38
55
|
function typeOfAction(value) {
|
|
39
56
|
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"
|
|
40
57
|
? value.type
|
|
41
58
|
: undefined;
|
|
42
59
|
}
|
|
60
|
+
function hasVscodeClientMeta(value) {
|
|
61
|
+
return (typeof value === "object" &&
|
|
62
|
+
value !== null &&
|
|
63
|
+
("vscode.telemetryLevel" in value || "vscode.clientConnectionKind" in value));
|
|
64
|
+
}
|
|
43
65
|
/**
|
|
44
|
-
* VS Code
|
|
45
|
-
*
|
|
66
|
+
* Allows VS Code to clean up only sessions known to be unused drafts on this
|
|
67
|
+
* connection. Unknown sessions are protected: reconnect does not carry enough
|
|
68
|
+
* history to prove that they are empty.
|
|
46
69
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
* Remove this tracker when VS Code graduates materialized remote sessions
|
|
71
|
+
* before tearing down its provisional-session service.
|
|
72
|
+
*/
|
|
73
|
+
class VscodeSessionDisposalGuard {
|
|
74
|
+
#sessions = new Map();
|
|
75
|
+
#pendingRequests = new Map();
|
|
76
|
+
applyToIncoming(message, params) {
|
|
77
|
+
const channel = typeof params.channel === "string" ? params.channel : undefined;
|
|
78
|
+
const actionType = typeOfAction(params.action);
|
|
79
|
+
if (channel && actionType && MATERIALIZING_ACTIONS.has(actionType)) {
|
|
80
|
+
this.#markMaterialized(channel);
|
|
81
|
+
}
|
|
82
|
+
if (!("id" in message) || !channel) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const session = canonicalSessionUri(channel);
|
|
86
|
+
if (!session) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (message.method === "createSession") {
|
|
90
|
+
this.#sessions.set(session, params.importConversation === undefined ? "creating" : "materialized");
|
|
91
|
+
this.#pendingRequests.set(message.id, { kind: "create", session });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (message.method !== "disposeSession") {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (this.#sessions.get(session) !== "empty") {
|
|
98
|
+
throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, VSCODE_MATERIALIZED_SESSION_DISPOSAL_REFUSAL);
|
|
99
|
+
}
|
|
100
|
+
this.#sessions.set(session, "disposing");
|
|
101
|
+
this.#pendingRequests.set(message.id, { kind: "dispose", session });
|
|
102
|
+
}
|
|
103
|
+
applyToOutgoing(message) {
|
|
104
|
+
if (!("method" in message)) {
|
|
105
|
+
this.#applyResponse(message);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const params = message.params;
|
|
109
|
+
if (!params) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const actionType = typeOfAction(params.action);
|
|
113
|
+
if (message.method === "action" &&
|
|
114
|
+
params.rejectionReason === undefined &&
|
|
115
|
+
actionType !== undefined &&
|
|
116
|
+
MATERIALIZING_ACTIONS.has(actionType) &&
|
|
117
|
+
typeof params.channel === "string") {
|
|
118
|
+
this.#markMaterialized(params.channel);
|
|
119
|
+
}
|
|
120
|
+
if (message.method === "root/sessionRemoved" && typeof params.session === "string") {
|
|
121
|
+
const session = canonicalSessionUri(params.session);
|
|
122
|
+
if (session)
|
|
123
|
+
this.#sessions.delete(session);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
#applyResponse(message) {
|
|
127
|
+
const pending = this.#pendingRequests.get(message.id);
|
|
128
|
+
if (!pending) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.#pendingRequests.delete(message.id);
|
|
132
|
+
const state = this.#sessions.get(pending.session);
|
|
133
|
+
if (pending.kind === "create") {
|
|
134
|
+
if ("result" in message) {
|
|
135
|
+
if (state === "creating")
|
|
136
|
+
this.#sessions.set(pending.session, "empty");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
this.#sessions.delete(pending.session);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if ("result" in message) {
|
|
144
|
+
this.#sessions.delete(pending.session);
|
|
145
|
+
}
|
|
146
|
+
else if (state === "disposing") {
|
|
147
|
+
this.#sessions.set(pending.session, "empty");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
#markMaterialized(channel) {
|
|
151
|
+
const session = owningSessionUri(channel);
|
|
152
|
+
if (session && this.#sessions.has(session)) {
|
|
153
|
+
this.#sessions.set(session, "materialized");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* VS Code computes provider-scoped session URIs and derived default-chat URIs
|
|
159
|
+
* instead of using the canonical resources published by the host. It also
|
|
160
|
+
* targets `completions` at the session URI rather than the chat URI. Publishing
|
|
161
|
+
* those shapes globally would impose one client's dialect on every client, so
|
|
162
|
+
* translation remains per connection and core services see canonical URIs.
|
|
66
163
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* provider-
|
|
70
|
-
*
|
|
71
|
-
* second translation. After a host restart, reconnect subscriptions provide the
|
|
72
|
-
* same fingerprints. Replies use each connection's dialect while core services
|
|
73
|
-
* see canonical URIs.
|
|
164
|
+
* `initialize.clientInfo`, VS Code's namespaced request metadata, and derived
|
|
165
|
+
* chat URIs identify VS Code and gate its workarounds. Independently, a raw
|
|
166
|
+
* `pi:/...` target selects the provider-session URI dialect used by the iOS
|
|
167
|
+
* client. Both observations remain per connection; core services stay canonical.
|
|
74
168
|
*
|
|
75
|
-
*
|
|
169
|
+
* Remove each compatibility branch when its clients emit canonical AHP traffic.
|
|
76
170
|
*/
|
|
77
171
|
export class ClientWorkarounds {
|
|
78
|
-
#
|
|
79
|
-
|
|
172
|
+
#isVscode = false;
|
|
173
|
+
#usesProviderSessionUris = false;
|
|
174
|
+
#vscodeSessionDisposal = new VscodeSessionDisposalGuard();
|
|
175
|
+
/** Reads implementation identity without erasing observations from wire traffic. */
|
|
80
176
|
identify(clientInfo) {
|
|
81
177
|
if (VSCODE_CLIENT_NAMES.has(clientInfo?.name ?? ""))
|
|
82
|
-
this.#
|
|
178
|
+
this.#isVscode = true;
|
|
83
179
|
}
|
|
84
180
|
/** Rewrites this connection's parsed request or notification in place. */
|
|
85
181
|
applyToIncoming(message) {
|
|
86
182
|
const params = message.params;
|
|
87
183
|
if (!params)
|
|
88
184
|
return;
|
|
89
|
-
this.#
|
|
90
|
-
|
|
185
|
+
this.#observeTraffic(message.method, params);
|
|
186
|
+
// Remove when VS Code includes the AHP 0.9 root-channel discriminant in reconnect.
|
|
187
|
+
if ("id" in message && this.#isVscode && message.method === "reconnect" && params.channel === undefined) {
|
|
188
|
+
params.channel = ROOT_CHANNEL;
|
|
189
|
+
}
|
|
190
|
+
const dialect = this.#uriDialect();
|
|
191
|
+
const rewrite = (uri) => inbound(uri, dialect);
|
|
91
192
|
if (typeof params.channel === "string") {
|
|
92
193
|
let channel = rewrite(params.channel);
|
|
93
|
-
if (message.method === "completions" &&
|
|
194
|
+
if (message.method === "completions" && dialect !== "canonical") {
|
|
94
195
|
// Both VS Code and the iOS client currently target completions at the
|
|
95
196
|
// provider-style session URI.
|
|
96
197
|
const sessionId = providerSessionId(channel);
|
|
@@ -99,7 +200,7 @@ export class ClientWorkarounds {
|
|
|
99
200
|
// VS Code addresses its session rename to the selected chat. AHP defines
|
|
100
201
|
// `session/titleChanged` only on the owning session.
|
|
101
202
|
if (message.method === "dispatchAction" &&
|
|
102
|
-
this.#
|
|
203
|
+
this.#isVscode &&
|
|
103
204
|
typeOfAction(params.action) === ActionType.SessionTitleChanged) {
|
|
104
205
|
const chatId = chatIdFromUri(channel);
|
|
105
206
|
channel = chatId ? sessionUri(chatId) : channel;
|
|
@@ -114,34 +215,49 @@ export class ClientWorkarounds {
|
|
|
114
215
|
params[field] = subscriptions.map((uri) => (typeof uri === "string" ? rewrite(uri) : uri));
|
|
115
216
|
}
|
|
116
217
|
}
|
|
218
|
+
if (this.#isVscode) {
|
|
219
|
+
this.#vscodeSessionDisposal.applyToIncoming(message, params);
|
|
220
|
+
}
|
|
117
221
|
}
|
|
118
|
-
/** Returns the message
|
|
119
|
-
|
|
120
|
-
|
|
222
|
+
/** Returns the outgoing message, rewritten if this client needs it. */
|
|
223
|
+
applyToOutgoing(message) {
|
|
224
|
+
if (this.#isVscode) {
|
|
225
|
+
this.#vscodeSessionDisposal.applyToOutgoing(message);
|
|
226
|
+
}
|
|
227
|
+
const dialect = this.#uriDialect();
|
|
121
228
|
return dialect === "canonical"
|
|
122
229
|
? message
|
|
123
230
|
: rewriteFields(message, (uri) => outbound(uri, dialect));
|
|
124
231
|
}
|
|
125
|
-
#
|
|
232
|
+
#observeTraffic(method, params) {
|
|
126
233
|
const direct = typeof params.channel === "string" ? params.channel : undefined;
|
|
127
234
|
const listed = [
|
|
128
235
|
...(Array.isArray(params.initialSubscriptions) ? params.initialSubscriptions : []),
|
|
129
236
|
...(Array.isArray(params.subscriptions) ? params.subscriptions : []),
|
|
130
237
|
];
|
|
131
238
|
const observed = direct ? [direct, ...listed] : listed;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
else if (this.#dialect === "canonical" &&
|
|
239
|
+
this.#observeVscode(params, observed);
|
|
240
|
+
if (!this.#usesProviderSessionUris &&
|
|
136
241
|
(PROVIDER_SESSION_METHODS.has(method) ? observed : listed).some((uri) => typeof uri === "string" && isProviderSession(uri))) {
|
|
137
|
-
this.#
|
|
242
|
+
this.#usesProviderSessionUris = true;
|
|
138
243
|
}
|
|
139
244
|
}
|
|
245
|
+
#observeVscode(params, observed) {
|
|
246
|
+
if (hasVscodeClientMeta(params._meta) ||
|
|
247
|
+
observed.some((uri) => typeof uri === "string" && sessionFromDerivedChat(uri) !== undefined)) {
|
|
248
|
+
this.#isVscode = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
#uriDialect() {
|
|
252
|
+
if (this.#isVscode)
|
|
253
|
+
return "vscode";
|
|
254
|
+
return this.#usesProviderSessionUris ? "provider" : "canonical";
|
|
255
|
+
}
|
|
140
256
|
}
|
|
141
257
|
/**
|
|
142
258
|
* Translates a URI this client computed into the one this host minted.
|
|
143
259
|
*
|
|
144
|
-
* A derived chat URI is unwrapped
|
|
260
|
+
* A derived chat URI is unwrapped regardless of who sent it โ it names no channel this
|
|
145
261
|
* host could otherwise serve. A provider-aliased session URI is only rewritten
|
|
146
262
|
* for a client identified as using that alias; unknown schemes are left alone.
|
|
147
263
|
*/
|
package/dist/core/connection.js
CHANGED
|
@@ -10,7 +10,7 @@ export class ClientConnection {
|
|
|
10
10
|
this.transport = transport;
|
|
11
11
|
}
|
|
12
12
|
send(message) {
|
|
13
|
-
this.transport.send(this.workarounds.
|
|
13
|
+
this.transport.send(this.workarounds.applyToOutgoing(message));
|
|
14
14
|
}
|
|
15
15
|
subscribe(channel) {
|
|
16
16
|
this.subscriptions.add(channel);
|
package/dist/core/host.js
CHANGED
|
@@ -68,7 +68,6 @@ function validateInitialize(params) {
|
|
|
68
68
|
if (params.capabilities !== undefined && !isRecord(params.capabilities)) {
|
|
69
69
|
throw ProtocolError.invalidParams("capabilities must be an object");
|
|
70
70
|
}
|
|
71
|
-
return negotiateProtocolVersion(params.protocolVersions);
|
|
72
71
|
}
|
|
73
72
|
function validateReconnect(params) {
|
|
74
73
|
if (typeof params?.clientId !== "string" || params.clientId.length === 0) {
|
|
@@ -100,6 +99,21 @@ export class AhpHost {
|
|
|
100
99
|
get store() {
|
|
101
100
|
return this.#store;
|
|
102
101
|
}
|
|
102
|
+
/** Removes a channel and releases every connection subscribed to that identity. */
|
|
103
|
+
deleteChannel(channel) {
|
|
104
|
+
const deleted = this.#store.delete(channel);
|
|
105
|
+
let released = false;
|
|
106
|
+
for (const connection of this.#connections) {
|
|
107
|
+
if (connection.isSubscribed(channel)) {
|
|
108
|
+
connection.unsubscribe(channel);
|
|
109
|
+
released = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (released) {
|
|
113
|
+
this.#notifySubscriberCount(channel);
|
|
114
|
+
}
|
|
115
|
+
return deleted;
|
|
116
|
+
}
|
|
103
117
|
get serverSeq() {
|
|
104
118
|
return this.#sequencer.current;
|
|
105
119
|
}
|
|
@@ -156,9 +170,6 @@ export class AhpHost {
|
|
|
156
170
|
}
|
|
157
171
|
}
|
|
158
172
|
async #dispatchRequest(connection, request) {
|
|
159
|
-
if (ROOT_COMMANDS.has(request.method) && readChannel(request.params) !== ROOT_CHANNEL) {
|
|
160
|
-
throw ProtocolError.invalidParams(`${request.method} requires channel ${ROOT_CHANNEL}`);
|
|
161
|
-
}
|
|
162
173
|
const handshake = request.method === "initialize" || request.method === "reconnect";
|
|
163
174
|
if (handshake && connection.clientId) {
|
|
164
175
|
throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, "Connection is already initialized");
|
|
@@ -169,8 +180,9 @@ export class AhpHost {
|
|
|
169
180
|
let protocolVersion = "";
|
|
170
181
|
if (request.method === "initialize") {
|
|
171
182
|
const params = request.params;
|
|
172
|
-
|
|
173
|
-
|
|
183
|
+
validateInitialize(params);
|
|
184
|
+
protocolVersion = negotiateProtocolVersion(params.protocolVersions);
|
|
185
|
+
connection.workarounds.identify(params.clientInfo ?? this.#clientInfoById.get(params.clientId));
|
|
174
186
|
}
|
|
175
187
|
else if (request.method === "reconnect") {
|
|
176
188
|
const params = request.params;
|
|
@@ -178,6 +190,9 @@ export class AhpHost {
|
|
|
178
190
|
connection.workarounds.identify(this.#clientInfoById.get(params.clientId));
|
|
179
191
|
}
|
|
180
192
|
connection.workarounds.applyToIncoming(request);
|
|
193
|
+
if (ROOT_COMMANDS.has(request.method) && readChannel(request.params) !== ROOT_CHANNEL) {
|
|
194
|
+
throw ProtocolError.invalidParams(`${request.method} requires channel ${ROOT_CHANNEL}`);
|
|
195
|
+
}
|
|
181
196
|
switch (request.method) {
|
|
182
197
|
// `ping` must be answered whether or not the client has completed
|
|
183
198
|
// `initialize` or holds any subscription.
|
|
@@ -346,7 +361,6 @@ export class AhpHost {
|
|
|
346
361
|
this.#bindClient(connection, params.clientId);
|
|
347
362
|
const identifiedClient = params.clientInfo ?? this.#clientInfoById.get(params.clientId);
|
|
348
363
|
this.#clientInfoById.set(params.clientId, identifiedClient);
|
|
349
|
-
connection.workarounds.identify(identifiedClient);
|
|
350
364
|
const snapshots = [];
|
|
351
365
|
for (const uri of params.initialSubscriptions ?? []) {
|
|
352
366
|
if (!this.#store.has(uri)) {
|
package/dist/core/sequencer.js
CHANGED
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
* @see https://microsoft.github.io/agent-host-protocol/specification/lifecycle
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* snapshots instead โ correct, just more expensive.
|
|
12
|
+
* Local memory bound for replay. A client whose gap predates the buffer gets
|
|
13
|
+
* fresh snapshots instead, preserving correctness at the cost of more data.
|
|
15
14
|
*/
|
|
16
15
|
const DEFAULT_REPLAY_BUFFER_CAPACITY = 1000;
|
|
17
16
|
export class Sequencer {
|
package/dist/core/uri.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* URI helpers shared across channels and backends.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* lives here rather than in whichever module happened to need it first, because
|
|
6
|
-
* having two conversions is how they drift: a hand-rolled
|
|
7
|
-
* `decodeURIComponent(new URL(uri).pathname)` silently turns the UNC URI
|
|
8
|
-
* `file://host/share/x` into the local path `/share/x`, while `node:url`
|
|
9
|
-
* rejects it โ so the same input was accepted in one command and refused in
|
|
10
|
-
* another.
|
|
2
|
+
* URI helpers shared across channels and backends. Filesystem conversion uses
|
|
3
|
+
* `node:url` consistently so platform and remote-authority semantics cannot
|
|
4
|
+
* diverge between commands.
|
|
11
5
|
*/
|
|
12
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
7
|
/**
|
package/dist/host/pi-host.js
CHANGED
|
@@ -9,7 +9,7 @@ import { AhpHost } from "../core/host.js";
|
|
|
9
9
|
import { CompletionService, MENTION_TRIGGER } from "../pi/completions.js";
|
|
10
10
|
import { deleteSessionFile } from "../pi/delete-session.js";
|
|
11
11
|
import { InProcessPiBackend } from "../pi/in-process-backend.js";
|
|
12
|
-
import { buildAgentInfo, THINKING_CONFIG_KEY } from "../pi/models.js";
|
|
12
|
+
import { buildAgentInfo, modelSelectionId, THINKING_CONFIG_KEY } from "../pi/models.js";
|
|
13
13
|
import { resolveProjectTrust } from "../pi/project-trust.js";
|
|
14
14
|
import { ResourcePathPolicy } from "../pi/resource-paths.js";
|
|
15
15
|
import { ResourceService } from "../pi/resource-service.js";
|
|
@@ -20,12 +20,9 @@ import { SessionHydrator } from "../pi/session-hydrator.js";
|
|
|
20
20
|
import { SessionRegistry } from "../pi/session-registry.js";
|
|
21
21
|
import { TerminalService } from "./terminal-service.js";
|
|
22
22
|
/**
|
|
23
|
-
* Builds the host and registers every channel
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* The model list is read once at startup. Refreshing it later means dispatching
|
|
27
|
-
* `root/agentsChanged`, which is what makes the agent list a state channel
|
|
28
|
-
* rather than a one-shot handshake field.
|
|
23
|
+
* Builds the host and registers every supported channel. The model list is a
|
|
24
|
+
* startup snapshot; dynamic refresh would require publishing
|
|
25
|
+
* `root/agentsChanged` and is not implemented.
|
|
29
26
|
*/
|
|
30
27
|
export async function createPiHost(options = {}) {
|
|
31
28
|
const workingDirectory = options.workingDirectory ?? process.cwd();
|
|
@@ -65,7 +62,9 @@ export async function createPiHost(options = {}) {
|
|
|
65
62
|
const fallbackThinking = fallback
|
|
66
63
|
? clampThinkingLevel(fallback, configured ? (settingsManager?.getDefaultThinkingLevel() ?? "medium") : "medium")
|
|
67
64
|
: undefined;
|
|
68
|
-
const fallbackSelection = () => fallback && fallbackThinking
|
|
65
|
+
const fallbackSelection = () => fallback && fallbackThinking
|
|
66
|
+
? { id: modelSelectionId(fallback), config: { [THINKING_CONFIG_KEY]: fallbackThinking } }
|
|
67
|
+
: undefined;
|
|
69
68
|
const catalogue = new PiSessionCatalogue();
|
|
70
69
|
const resourcePaths = new ResourcePathPolicy(options.resourceRoots);
|
|
71
70
|
const resources = new ResourceService({ pathPolicy: resourcePaths });
|
|
@@ -88,7 +87,7 @@ export async function createPiHost(options = {}) {
|
|
|
88
87
|
defaultWorkingDirectory: workingDirectory,
|
|
89
88
|
createBackend,
|
|
90
89
|
defaultSelection: fallbackSelection,
|
|
91
|
-
deleteFile: options.deleteFile ??
|
|
90
|
+
deleteFile: options.deleteFile ?? deleteSessionFile,
|
|
92
91
|
findSessionFile: (id) => catalogue.findSessionFile(id),
|
|
93
92
|
...(options.log ? { log: options.log } : {}),
|
|
94
93
|
});
|
|
@@ -116,8 +115,8 @@ export async function createPiHost(options = {}) {
|
|
|
116
115
|
},
|
|
117
116
|
sessions: {
|
|
118
117
|
create(params) {
|
|
119
|
-
// The registry's narrower structural type
|
|
120
|
-
//
|
|
118
|
+
// The registry's narrower structural type makes the unsupported optional
|
|
119
|
+
// fields explicit: config, activeClient, and progressToken are ignored.
|
|
121
120
|
sessions.create(params);
|
|
122
121
|
},
|
|
123
122
|
dispose(channel) {
|
|
@@ -131,6 +130,7 @@ export async function createPiHost(options = {}) {
|
|
|
131
130
|
host,
|
|
132
131
|
catalogue,
|
|
133
132
|
isLive: (session) => sessions.has(session),
|
|
133
|
+
isDisposing: (session) => sessions.isDisposing(session),
|
|
134
134
|
// Adopted without a backend; one starts on the first turn.
|
|
135
135
|
adopt: (session) => void sessions.adopt(session),
|
|
136
136
|
fallbackSelection,
|
|
@@ -31,7 +31,12 @@ function defaultShell() {
|
|
|
31
31
|
return process.env.SHELL || "sh";
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Bridges terminal actions to independent pseudoterminal processes. `StateStore`
|
|
36
|
+
* owns every protocol-visible `TerminalState`; the runtime map holds only PTY
|
|
37
|
+
* handles and output awaiting publication. Terminals are currently client-owned,
|
|
38
|
+
* so claim transfer is rejected rather than represented in a second state store.
|
|
39
|
+
*/
|
|
35
40
|
export class TerminalService {
|
|
36
41
|
#host;
|
|
37
42
|
#options;
|
package/dist/pi/activity.js
CHANGED