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 CHANGED
@@ -1,10 +1,113 @@
1
1
  # pi-ahp
2
2
 
3
+ [![CI](https://github.com/Qusic/pi-ahp/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/Qusic/pi-ahp/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/pi-ahp)](https://www.npmjs.com/package/pi-ahp)
5
+ [![pi](https://img.shields.io/npm/dependency-version/pi-ahp/@earendil-works/pi-coding-agent?label=pi)](https://github.com/earendil-works/pi)
6
+ [![AHP](https://img.shields.io/npm/dependency-version/pi-ahp/@microsoft/agent-host-protocol?label=AHP)](https://github.com/microsoft/agent-host-protocol)
7
+ [![License](https://img.shields.io/npm/l/pi-ahp)](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 testing currently focuses on [Agent Host support in Visual Studio Code](https://code.visualstudio.com/docs/agents/concepts/agent-host), with [Agent Console](https://qusic.github.io/agent-console/) providing additional AHP client coverage. The host is not limited to either client.
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
- The project is currently under development and has not been released or distributed yet.
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
 
@@ -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` (or
7
- * `session/creationFailed`) once the backend is up, so a slow agent start never
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
- /** Bits 0โ€“4 are mutually exclusive activity; later bits are session metadata. */
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 { chatIdFromUri, chatUri, sessionIdFromUri, sessionUri } from "./channels.js";
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 VS Code will address sessions under, being this host's agent
18
- * provider name. Hard-coded rather than threaded down from the pi layer:
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 addresses sessions and chats at URIs it computes, not the ones it was
45
- * given.
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
- * Three assumptions drive it, none of them in the protocol:
48
- *
49
- * - A session lives at `<agentProvider>:/<id>`. `agentHostSessionHandler`
50
- * rebuilds the URI that way from the provider name rather than using the
51
- * `resource` the host published. It has a `sessionSchemeAlias` config for
52
- * exactly this mismatch โ€” "sessions are `ahp-session:/<id>` while the agent
53
- * is `copilot`" โ€” but only its cloud sandbox provider sets it.
54
- * - A session's default chat lives at `ahp-chat://default/<base64url(session)>`,
55
- * derived so producer and consumer "can compute it without a lookup table".
56
- * `SessionState.defaultChat` and the session's chat list are both ignored.
57
- * - `completions` targets the backend session URI even though its `channel`
58
- * is specified as a chat URI. VS Code's host accepts either and silently
59
- * chooses the default chat; this host keeps that tolerance per connection.
60
- *
61
- * So nothing this host sends is addressable by it, and every subscription
62
- * misses: sessions list, and opening one shows an empty transcript. Publishing
63
- * its shapes instead would push a base64 blob and a provider-specific scheme
64
- * onto every other client, so the translation is per connection and only for
65
- * the clients that need it.
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
- * Normally VS Code is identified at `initialize`, before the session list and
68
- * snapshots go out. A raw `pi:/...` target separately identifies only the
69
- * provider-style session scheme, which covers the iOS client without forcing
70
- * VS Code's derived-chat format on it. Observing a derived chat enables that
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
- * Goes when VS Code addresses what it was given.
169
+ * Remove each compatibility branch when its clients emit canonical AHP traffic.
76
170
  */
77
171
  export class ClientWorkarounds {
78
- #dialect = "canonical";
79
- /** Reads implementation identity without erasing a dialect inferred from URIs. */
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.#dialect = "vscode";
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.#observeDialect(message.method, params);
90
- const rewrite = (uri) => inbound(uri, this.#dialect);
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" && this.#dialect !== "canonical") {
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.#dialect === "vscode" &&
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 to send, rewritten if this client needs it. */
119
- applyToMessage(message) {
120
- const dialect = this.#dialect;
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
- #observeDialect(method, params) {
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
- if (observed.some((uri) => typeof uri === "string" && sessionFromDerivedChat(uri) !== undefined)) {
133
- this.#dialect = "vscode";
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.#dialect = "provider";
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 whoever sent it โ€” it names no channel this
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
  */
@@ -10,7 +10,7 @@ export class ClientConnection {
10
10
  this.transport = transport;
11
11
  }
12
12
  send(message) {
13
- this.transport.send(this.workarounds.applyToMessage(message));
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
- protocolVersion = validateInitialize(params);
173
- connection.workarounds.identify(params?.clientInfo);
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)) {
@@ -9,9 +9,8 @@
9
9
  * @see https://microsoft.github.io/agent-host-protocol/specification/lifecycle
10
10
  */
11
11
  /**
12
- * How many envelopes to retain for replay. Matches the reference host's
13
- * `REPLAY_BUFFER_CAPACITY`. A client whose gap predates the buffer gets fresh
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
- * The protocol carries filesystem locations as `file:` URIs. Converting them
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
  /**
@@ -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 a client can reach at this
24
- * milestone.
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 ? { id: fallback.id, config: { [THINKING_CONFIG_KEY]: fallbackThinking } } : undefined;
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 ?? ((path) => void deleteSessionFile(path)),
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 documents the optional fields
120
- // this milestone ignores: config, activeClient, and progressToken.
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
- /** Bridges terminal channel actions to independent pseudoterminal processes. */
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;
@@ -131,7 +131,6 @@ function searchInput(input) {
131
131
  }
132
132
  return parts.join(" ");
133
133
  }
134
- /** What to show between tool calls, while the model is producing output. */
135
134
  /**
136
135
  * The same description, phrased for a call that has finished.
137
136
  *