@grackle-ai/powerline 0.170.0 → 0.172.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/dist/ahp-handlers.d.ts +7 -44
- package/dist/ahp-handlers.d.ts.map +1 -1
- package/dist/ahp-handlers.js +21 -1024
- package/dist/ahp-handlers.js.map +1 -1
- package/dist/ahp-types.d.ts +92 -0
- package/dist/ahp-types.d.ts.map +1 -0
- package/dist/ahp-types.js +19 -0
- package/dist/ahp-types.js.map +1 -0
- package/dist/channel-codec.d.ts +31 -0
- package/dist/channel-codec.d.ts.map +1 -0
- package/dist/channel-codec.js +38 -0
- package/dist/channel-codec.js.map +1 -0
- package/dist/forwarder.d.ts +26 -0
- package/dist/forwarder.d.ts.map +1 -0
- package/dist/forwarder.js +223 -0
- package/dist/forwarder.js.map +1 -0
- package/dist/handlers/action-handlers.d.ts +12 -0
- package/dist/handlers/action-handlers.d.ts.map +1 -0
- package/dist/handlers/action-handlers.js +49 -0
- package/dist/handlers/action-handlers.js.map +1 -0
- package/dist/handlers/session-handlers.d.ts +16 -0
- package/dist/handlers/session-handlers.d.ts.map +1 -0
- package/dist/handlers/session-handlers.js +262 -0
- package/dist/handlers/session-handlers.js.map +1 -0
- package/dist/handlers/subscribe-handlers.d.ts +10 -0
- package/dist/handlers/subscribe-handlers.d.ts.map +1 -0
- package/dist/handlers/subscribe-handlers.js +82 -0
- package/dist/handlers/subscribe-handlers.js.map +1 -0
- package/dist/index.js +11 -25
- package/dist/index.js.map +1 -1
- package/dist/resource-watch.d.ts +30 -0
- package/dist/resource-watch.d.ts.map +1 -0
- package/dist/resource-watch.js +169 -0
- package/dist/resource-watch.js.map +1 -0
- package/dist/runtime-loader.d.ts +9 -0
- package/dist/runtime-loader.d.ts.map +1 -0
- package/dist/runtime-loader.js +69 -0
- package/dist/runtime-loader.js.map +1 -0
- package/package.json +10 -10
package/dist/ahp-handlers.js
CHANGED
|
@@ -2,131 +2,23 @@
|
|
|
2
2
|
* PowerLine AHP handlers (AHP HR8d / #1336).
|
|
3
3
|
*
|
|
4
4
|
* Mounts an {@link AhpServerSocket} on an existing HTTP/HTTP2 server and
|
|
5
|
-
* routes incoming AHP JSON-RPC requests + notifications to the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Wire-protocol summary (per #1336, Option E):
|
|
10
|
-
*
|
|
11
|
-
* - `initialize` — handshake, return canned `InitializeResult` (no host
|
|
12
|
-
* state; PowerLine session channels are declared stateless).
|
|
13
|
-
* - `createSession` — interpret `params.config` as Grackle's spawn shape.
|
|
14
|
-
* If `config.resumeFromRuntimeSessionId` is present, call `runtime.resume`;
|
|
15
|
-
* else call `runtime.spawn`. Store the resulting `AgentSession` in the
|
|
16
|
-
* existing in-memory registry.
|
|
17
|
-
* - `subscribe` — set up the per-(session, client) forwarder: drain any
|
|
18
|
-
* parked events for the session as `action` notifications first, then
|
|
19
|
-
* forward each live `AgentEvent` from `session.stream()` through the
|
|
20
|
-
* forward mapper as `action` notifications. Return
|
|
21
|
-
* `SubscribeResult { snapshot: undefined }` — session channels are
|
|
22
|
-
* stateless from AHP's POV; state is conveyed via the action stream.
|
|
23
|
-
* - `dispatchAction` notification — if the action is
|
|
24
|
-
* `SessionTurnStartedAction`, route the `message.text` to
|
|
25
|
-
* `session.sendInput`. Other client-dispatchable actions are no-ops
|
|
26
|
-
* for now (none used by Grackle today).
|
|
27
|
-
* - `disposeSession` — `session.kill()` + remove from registry.
|
|
28
|
-
* - `listSessions` — map the in-memory registry to `ListSessionsResult`.
|
|
29
|
-
* - `authenticate` — interpret `params.resource` as
|
|
30
|
-
* `grackle://provider/{provider}/{name}`; parse `params.token` as a
|
|
31
|
-
* JSON-encoded `{ type, envVar?, filePath?, value }`. Dispatch to the
|
|
32
|
-
* existing HR6 credential delivery.
|
|
33
|
-
* - `ping` — return `null`.
|
|
34
|
-
*
|
|
35
|
-
* On disconnect (heartbeat timeout or client close), enumerate this
|
|
36
|
-
* client's active sessions, kill each agent, drain its buffered queue, and
|
|
37
|
-
* park the events in the existing in-memory map for replay on next
|
|
38
|
-
* `subscribe`.
|
|
5
|
+
* routes incoming AHP JSON-RPC requests + notifications to the handler
|
|
6
|
+
* modules. This file is the glue layer — individual handler logic lives in
|
|
7
|
+
* the `handlers/` directory and domain modules (`forwarder.ts`,
|
|
8
|
+
* `resource-watch.ts`).
|
|
39
9
|
*
|
|
40
10
|
* @module ahp-handlers
|
|
41
11
|
*/
|
|
42
|
-
import {
|
|
43
|
-
import { AhpServerSocket
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
-
import { assertWithinRoots, isResourceError, listResource, readResource, ResourceError, resourceUriToPath, } from "./resource-fs.js";
|
|
53
|
-
import { logger } from "./logger.js";
|
|
54
|
-
import { coalesceChangeType, COALESCE_DROP } from "./resource-watch-coalesce.js";
|
|
55
|
-
import { getRuntime } from "./runtime-registry.js";
|
|
56
|
-
import { deleteSessionPump, drainParkedSession, getSession, getSessionPump, isParked, listAllSessions, parkSession, registerPumpForwarder, removeSession, startSessionPump, unregisterPumpForwarder, } from "./session-mgr.js";
|
|
57
|
-
import { writeTokens } from "./token-writer.js";
|
|
58
|
-
const PROTOCOL_VERSION = "0.1.0";
|
|
59
|
-
const SESSION_CHANNEL_PREFIX = "ahp-session:/";
|
|
60
|
-
const RESOURCE_WATCH_CHANNEL_PREFIX = "ahp-resource-watch:/";
|
|
61
|
-
/**
|
|
62
|
-
* Window over which raw filesystem events are coalesced into a single
|
|
63
|
-
* `resourceWatch/changed` action batch, to keep the action stream tractable
|
|
64
|
-
* under bursty writes (e.g. an editor's atomic save).
|
|
65
|
-
*/
|
|
66
|
-
const WATCH_COALESCE_MS = 75;
|
|
67
|
-
/**
|
|
68
|
-
* Maximum number of concurrent resource watches a single connection may hold.
|
|
69
|
-
* Each subscribed watch consumes OS file-watch descriptors, so this bounds a
|
|
70
|
-
* buggy or hostile client's ability to exhaust them. Generous for any real
|
|
71
|
-
* document-viewer consumer.
|
|
72
|
-
*/
|
|
73
|
-
const MAX_RESOURCE_WATCHES_PER_CONNECTION = 64;
|
|
74
|
-
/**
|
|
75
|
-
* Validate an optional AHP `GlobSet` ({@link ResourceWatchState.excludes} /
|
|
76
|
-
* `includes`) from untrusted client params: it must be `undefined` or an object
|
|
77
|
-
* with an `items` array of strings.
|
|
78
|
-
*
|
|
79
|
-
* @throws ResourceError with `InvalidParams` for any other shape.
|
|
80
|
-
*/
|
|
81
|
-
function assertGlobSet(value, field) {
|
|
82
|
-
if (value === undefined) {
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
const items = typeof value === "object" && value !== null ? value.items : undefined;
|
|
86
|
-
if (!Array.isArray(items) || !items.every((g) => typeof g === "string")) {
|
|
87
|
-
throw new ResourceError(JsonRpcErrorCodes.InvalidParams, `createResourceWatch: ${field} must be { items: string[] }`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Decode a session URI to its underlying sessionId. Returns undefined for
|
|
92
|
-
* non-session URIs OR for the bare prefix `ahp-session:/` with no id
|
|
93
|
-
* (which would otherwise produce an empty sessionId and collide on
|
|
94
|
-
* createSession/subscribe/dispose).
|
|
95
|
-
*/
|
|
96
|
-
function sessionIdFromChannel(channel) {
|
|
97
|
-
if (!channel.startsWith(SESSION_CHANNEL_PREFIX)) {
|
|
98
|
-
return undefined;
|
|
99
|
-
}
|
|
100
|
-
const id = channel.slice(SESSION_CHANNEL_PREFIX.length);
|
|
101
|
-
return id.length > 0 ? id : undefined;
|
|
102
|
-
}
|
|
103
|
-
/** Encode a sessionId as an AHP session URI. */
|
|
104
|
-
function sessionChannel(sessionId) {
|
|
105
|
-
return `${SESSION_CHANNEL_PREFIX}${sessionId}`;
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Status-event contents that PowerLine rescues by synthesizing a
|
|
109
|
-
* `SessionMetaChangedAction` with `_meta.status`. mapAgentEvent drops these
|
|
110
|
-
* as "redundant with turn_* events", but Grackle's consumer relies on them
|
|
111
|
-
* to flip `session.status` in the UI.
|
|
112
|
-
*
|
|
113
|
-
* Includes the terminal statuses (`killed` / `terminated` / `failed`) a runtime
|
|
114
|
-
* emits on SIGTERM/abort — without them, a killed session's final status is
|
|
115
|
-
* dropped on the wire and the UI is left believing the session is still alive
|
|
116
|
-
* (#1356). They all map to `stopped` via the consumer's `mapSessionStatus`.
|
|
117
|
-
*
|
|
118
|
-
* Hoisted to module scope (not per-call) since the set is constant and
|
|
119
|
-
* `emitActionsForEvent` is a hot path.
|
|
120
|
-
*/
|
|
121
|
-
const STATUS_RESCUE_CONTENTS = new Set([
|
|
122
|
-
"running",
|
|
123
|
-
"waiting_input",
|
|
124
|
-
"completed",
|
|
125
|
-
"idle",
|
|
126
|
-
"killed",
|
|
127
|
-
"terminated",
|
|
128
|
-
"failed",
|
|
129
|
-
]);
|
|
12
|
+
import { JsonRpcErrorCodes } from "@grackle-ai/ahp";
|
|
13
|
+
import { AhpServerSocket } from "@grackle-ai/ahp-transport";
|
|
14
|
+
import { getOrCreateClientState } from "./ahp-types.js";
|
|
15
|
+
import { RESOURCE_WATCH_CHANNEL_PREFIX } from "./channel-codec.js";
|
|
16
|
+
import { handleAuthenticate, handleDispatchAction } from "./handlers/action-handlers.js";
|
|
17
|
+
import { handleCreateSession, handleDisposeSession, handleInitialize, handleListSessions, } from "./handlers/session-handlers.js";
|
|
18
|
+
import { handleSubscribe } from "./handlers/subscribe-handlers.js";
|
|
19
|
+
import { isResourceError, listResource, readResource } from "./resource-fs.js";
|
|
20
|
+
import { createResourceWatchEntry, stopResourceWatch } from "./resource-watch.js";
|
|
21
|
+
import { deleteSessionPump, getSession, getSessionPump, parkSession, removeSession, } from "./session-mgr.js";
|
|
130
22
|
/**
|
|
131
23
|
* Mount the AHP server on the given HTTP server, wiring all the PowerLine
|
|
132
24
|
* handlers. Returns the {@link AhpServerSocket} so the caller can close it.
|
|
@@ -137,17 +29,7 @@ const STATUS_RESCUE_CONTENTS = new Set([
|
|
|
137
29
|
export function mountAhpServer(opts) {
|
|
138
30
|
const clients = new Map();
|
|
139
31
|
function clientState(conn) {
|
|
140
|
-
|
|
141
|
-
if (state === undefined) {
|
|
142
|
-
state = {
|
|
143
|
-
sessionIds: new Set(),
|
|
144
|
-
forwarders: new Map(),
|
|
145
|
-
allowedRoots: new Set(),
|
|
146
|
-
watches: new Map(),
|
|
147
|
-
};
|
|
148
|
-
clients.set(conn.clientId, state);
|
|
149
|
-
}
|
|
150
|
-
return state;
|
|
32
|
+
return getOrCreateClientState(clients, conn.clientId);
|
|
151
33
|
}
|
|
152
34
|
function jsonRpcError(req, code, message) {
|
|
153
35
|
return {
|
|
@@ -163,880 +45,12 @@ export function mountAhpServer(opts) {
|
|
|
163
45
|
result,
|
|
164
46
|
};
|
|
165
47
|
}
|
|
166
|
-
/**
|
|
167
|
-
* Record the filesystem roots a newly-created session exposes for resource
|
|
168
|
-
* read/list/watch: the session's working directory and, when worktrees are
|
|
169
|
-
* enabled, its sibling worktree path (computed with the same {@link worktreeDir}
|
|
170
|
-
* the runtime uses, so the host and runtime agree on the location). Best
|
|
171
|
-
* effort — a session without a working directory contributes no root.
|
|
172
|
-
*/
|
|
173
|
-
function addSessionRoots(cState, cfg) {
|
|
174
|
-
const wd = typeof cfg.workingDirectory === "string" && cfg.workingDirectory !== ""
|
|
175
|
-
? cfg.workingDirectory
|
|
176
|
-
: undefined;
|
|
177
|
-
if (wd === undefined) {
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
const root = resolvePath(wd);
|
|
181
|
-
cState.allowedRoots.add(root);
|
|
182
|
-
const branch = typeof cfg.branch === "string" && cfg.branch !== "" ? cfg.branch : undefined;
|
|
183
|
-
// Mirror the runtime default: BaseAgentSession treats an omitted useWorktrees
|
|
184
|
-
// as `true` (`opts.useWorktrees ?? true`), so only an explicit `false` disables
|
|
185
|
-
// worktrees. If the host required an explicit `true` here, a session created
|
|
186
|
-
// with a branch and no useWorktrees would edit in the sibling worktree while
|
|
187
|
-
// the sandbox stayed pinned to the original working directory — rejecting the
|
|
188
|
-
// actual edited files with PermissionDenied.
|
|
189
|
-
const useWorktrees = cfg.useWorktrees !== false;
|
|
190
|
-
if (useWorktrees && branch !== undefined) {
|
|
191
|
-
cState.allowedRoots.add(worktreeDir(root, branch));
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
/** Translate a thrown {@link ResourceError} (or unknown error) into a wire response. */
|
|
195
48
|
function resourceErrorToResponse(req, err) {
|
|
196
49
|
if (isResourceError(err)) {
|
|
197
50
|
return jsonRpcError(req, err.code, err.message);
|
|
198
51
|
}
|
|
199
52
|
return jsonRpcError(req, JsonRpcErrorCodes.InternalError, err instanceof Error ? err.message : String(err));
|
|
200
53
|
}
|
|
201
|
-
// ─── handler bodies ───────────────────────────────────────────
|
|
202
|
-
function handleInitialize(_params) {
|
|
203
|
-
return {
|
|
204
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
205
|
-
serverSeq: 0,
|
|
206
|
-
snapshots: [],
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
function handleCreateSession(params, conn) {
|
|
210
|
-
const sessionId = sessionIdFromChannel(params.channel);
|
|
211
|
-
if (sessionId === undefined) {
|
|
212
|
-
return {
|
|
213
|
-
jsonrpc: "2.0",
|
|
214
|
-
id: 0, // overwritten by caller
|
|
215
|
-
error: {
|
|
216
|
-
code: JsonRpcErrorCodes.InvalidParams,
|
|
217
|
-
message: `createSession: channel must be ${SESSION_CHANNEL_PREFIX}<sessionId>`,
|
|
218
|
-
},
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
const runtimeName = params.provider;
|
|
222
|
-
if (runtimeName === undefined) {
|
|
223
|
-
return {
|
|
224
|
-
jsonrpc: "2.0",
|
|
225
|
-
id: 0,
|
|
226
|
-
error: {
|
|
227
|
-
code: JsonRpcErrorCodes.InvalidParams,
|
|
228
|
-
message: "createSession: `provider` is required",
|
|
229
|
-
},
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
const runtime = getRuntime(runtimeName);
|
|
233
|
-
if (runtime === undefined) {
|
|
234
|
-
return {
|
|
235
|
-
jsonrpc: "2.0",
|
|
236
|
-
id: 0,
|
|
237
|
-
error: {
|
|
238
|
-
code: JsonRpcErrorCodes.InvalidParams,
|
|
239
|
-
message: `Unknown runtime: ${runtimeName}`,
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
// Reject if a session with this ID already exists AND is not parked. A
|
|
244
|
-
// parked session can be reanimated; a live one would conflict.
|
|
245
|
-
const existing = getSession(sessionId);
|
|
246
|
-
if (existing !== undefined) {
|
|
247
|
-
return {
|
|
248
|
-
jsonrpc: "2.0",
|
|
249
|
-
id: 0,
|
|
250
|
-
error: {
|
|
251
|
-
code: JsonRpcErrorCodes.InvalidRequest,
|
|
252
|
-
message: `Session already active: ${sessionId}`,
|
|
253
|
-
},
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
const config = params.config ?? {};
|
|
257
|
-
const cfg = config;
|
|
258
|
-
const resumeId = typeof cfg.resumeFromRuntimeSessionId === "string"
|
|
259
|
-
? cfg.resumeFromRuntimeSessionId
|
|
260
|
-
: undefined;
|
|
261
|
-
let session;
|
|
262
|
-
try {
|
|
263
|
-
if (resumeId !== undefined) {
|
|
264
|
-
session = runtime.resume({
|
|
265
|
-
sessionId,
|
|
266
|
-
runtimeSessionId: resumeId,
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
const prompt = typeof cfg.prompt === "string" ? cfg.prompt : "";
|
|
271
|
-
const model = typeof cfg.model === "string" ? cfg.model : "";
|
|
272
|
-
const maxTurns = typeof cfg.maxTurns === "number" ? cfg.maxTurns : 0;
|
|
273
|
-
const branchVal = typeof cfg.branch === "string" && cfg.branch !== "" ? cfg.branch : undefined;
|
|
274
|
-
// Reject unsafe branch names at the boundary before they reach git (GHSA-vv65).
|
|
275
|
-
if (branchVal !== undefined) {
|
|
276
|
-
try {
|
|
277
|
-
validateGitBranchName(branchVal);
|
|
278
|
-
}
|
|
279
|
-
catch (err) {
|
|
280
|
-
return {
|
|
281
|
-
jsonrpc: "2.0",
|
|
282
|
-
id: 0,
|
|
283
|
-
error: {
|
|
284
|
-
code: JsonRpcErrorCodes.InvalidParams,
|
|
285
|
-
message: err instanceof Error ? err.message : "Invalid branch name",
|
|
286
|
-
},
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
const wdVal = typeof cfg.workingDirectory === "string" && cfg.workingDirectory !== ""
|
|
291
|
-
? cfg.workingDirectory
|
|
292
|
-
: undefined;
|
|
293
|
-
const useWorktrees = typeof cfg.useWorktrees === "boolean" ? cfg.useWorktrees : undefined;
|
|
294
|
-
const systemContext = typeof cfg.systemContext === "string" && cfg.systemContext !== ""
|
|
295
|
-
? cfg.systemContext
|
|
296
|
-
: undefined;
|
|
297
|
-
const workspaceId = typeof cfg.workspaceId === "string" && cfg.workspaceId !== ""
|
|
298
|
-
? cfg.workspaceId
|
|
299
|
-
: undefined;
|
|
300
|
-
const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
|
|
301
|
-
const mcpServersJson = typeof cfg.mcpServersJson === "string" ? cfg.mcpServersJson : "";
|
|
302
|
-
const mcpUrl = typeof cfg.mcpUrl === "string" ? cfg.mcpUrl : "";
|
|
303
|
-
const mcpToken = typeof cfg.mcpToken === "string" ? cfg.mcpToken : "";
|
|
304
|
-
const scriptContent = typeof cfg.scriptContent === "string" && cfg.scriptContent !== ""
|
|
305
|
-
? cfg.scriptContent
|
|
306
|
-
: undefined;
|
|
307
|
-
const pipe = typeof cfg.pipe === "string" && cfg.pipe !== ""
|
|
308
|
-
? cfg.pipe
|
|
309
|
-
: undefined;
|
|
310
|
-
session = runtime.spawn({
|
|
311
|
-
sessionId,
|
|
312
|
-
prompt,
|
|
313
|
-
model,
|
|
314
|
-
maxTurns,
|
|
315
|
-
...(branchVal !== undefined ? { branch: branchVal } : {}),
|
|
316
|
-
...(wdVal !== undefined ? { workingDirectory: wdVal } : {}),
|
|
317
|
-
...(useWorktrees !== undefined ? { useWorktrees } : {}),
|
|
318
|
-
...(systemContext !== undefined ? { systemContext } : {}),
|
|
319
|
-
...(workspaceId !== undefined ? { workspaceId } : {}),
|
|
320
|
-
...(taskId !== undefined ? { taskId } : {}),
|
|
321
|
-
mcpServers: mcpServersJson
|
|
322
|
-
? JSON.parse(mcpServersJson)
|
|
323
|
-
: undefined,
|
|
324
|
-
...(mcpUrl && mcpToken ? { mcpBroker: { url: mcpUrl, token: mcpToken } } : {}),
|
|
325
|
-
...(scriptContent !== undefined ? { scriptContent } : {}),
|
|
326
|
-
...(pipe !== undefined ? { pipe } : {}),
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
catch (err) {
|
|
331
|
-
return {
|
|
332
|
-
jsonrpc: "2.0",
|
|
333
|
-
id: 0,
|
|
334
|
-
error: {
|
|
335
|
-
code: JsonRpcErrorCodes.InternalError,
|
|
336
|
-
message: err instanceof Error ? err.message : String(err),
|
|
337
|
-
},
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
// Drive `session.stream()` exactly once via a per-session pump. Each AHP
|
|
341
|
-
// `subscribe` for this channel attaches a forwarder that tails the pump's
|
|
342
|
-
// buffer — re-entering `stream()` per subscribe would re-kick the
|
|
343
|
-
// runtime's driver (`BaseAgentSession.runSession`) and stack listeners on
|
|
344
|
-
// stub-style sessions. See ForwarderState.pos.
|
|
345
|
-
//
|
|
346
|
-
// The natural-exit hook prunes our `ClientState.sessionIds` set when the
|
|
347
|
-
// pump completes on its own (session.stream() returned without being torn
|
|
348
|
-
// down by dispose/onDisconnect). Without this the set would accumulate
|
|
349
|
-
// dead session IDs across the connection's lifetime. Capture clientId
|
|
350
|
-
// here so the closure doesn't keep `conn` alive.
|
|
351
|
-
const ownerClientId = conn.clientId;
|
|
352
|
-
startSessionPump(session, (deadSessionId) => {
|
|
353
|
-
const owner = clients.get(ownerClientId);
|
|
354
|
-
owner?.sessionIds.delete(deadSessionId);
|
|
355
|
-
});
|
|
356
|
-
const cState = clientState(conn);
|
|
357
|
-
cState.sessionIds.add(sessionId);
|
|
358
|
-
addSessionRoots(cState, cfg);
|
|
359
|
-
return {
|
|
360
|
-
jsonrpc: "2.0",
|
|
361
|
-
id: 0,
|
|
362
|
-
result: null,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* Allocate a resource-watch channel for a sandbox-validated URI. The watcher
|
|
367
|
-
* itself is started lazily when the client subscribes to the returned channel
|
|
368
|
-
* ({@link startResourceWatch}).
|
|
369
|
-
*
|
|
370
|
-
* @throws ResourceError — `InvalidParams`/`PermissionDenied` (sandbox) or
|
|
371
|
-
* `NotFound` if the watch target does not exist.
|
|
372
|
-
*/
|
|
373
|
-
async function createResourceWatchEntry(params, conn) {
|
|
374
|
-
const cState = clientState(conn);
|
|
375
|
-
if (cState.watches.size >= MAX_RESOURCE_WATCHES_PER_CONNECTION) {
|
|
376
|
-
throw new ResourceError(JsonRpcErrorCodes.InvalidParams, `Too many active resource watches (max ${MAX_RESOURCE_WATCHES_PER_CONNECTION})`);
|
|
377
|
-
}
|
|
378
|
-
// Validate the untrusted glob sets before storing them: startResourceWatch
|
|
379
|
-
// maps over `.items`, so a malformed `{ items: <non-array> }` would otherwise
|
|
380
|
-
// throw a raw TypeError at subscribe time (surfacing as InternalError instead
|
|
381
|
-
// of InvalidParams). Mirrors the encoding guard in readResource.
|
|
382
|
-
assertGlobSet(params.excludes, "excludes");
|
|
383
|
-
assertGlobSet(params.includes, "includes");
|
|
384
|
-
const rootPath = await assertWithinRoots(resourceUriToPath(params.uri), cState.allowedRoots);
|
|
385
|
-
if (!existsSync(rootPath)) {
|
|
386
|
-
throw new ResourceError(AhpErrorCodes.NotFound, `Watch target does not exist: ${params.uri}`);
|
|
387
|
-
}
|
|
388
|
-
const channel = `${RESOURCE_WATCH_CHANNEL_PREFIX}${randomUUID()}`;
|
|
389
|
-
const descriptor = {
|
|
390
|
-
root: params.uri,
|
|
391
|
-
recursive: params.recursive ?? false,
|
|
392
|
-
...(params.excludes !== undefined ? { excludes: params.excludes } : {}),
|
|
393
|
-
...(params.includes !== undefined ? { includes: params.includes } : {}),
|
|
394
|
-
};
|
|
395
|
-
cState.watches.set(channel, { rootPath, descriptor, serverSeq: 0, pending: new Map() });
|
|
396
|
-
return { channel };
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Flush the coalesced change batch for a watch as a single
|
|
400
|
-
* `resourceWatch/changed` action. Never dispatches an empty batch (per spec).
|
|
401
|
-
*/
|
|
402
|
-
function flushResourceWatch(conn, channel, entry) {
|
|
403
|
-
entry.flushTimer = undefined;
|
|
404
|
-
if (entry.stopped === true || entry.pending.size === 0) {
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
const items = [...entry.pending.values()];
|
|
408
|
-
entry.pending.clear();
|
|
409
|
-
const action = {
|
|
410
|
-
type: ActionType.ResourceWatchChanged,
|
|
411
|
-
changes: { items },
|
|
412
|
-
};
|
|
413
|
-
conn.session.notify("action", {
|
|
414
|
-
channel,
|
|
415
|
-
serverSeq: entry.serverSeq++,
|
|
416
|
-
action,
|
|
417
|
-
origin: undefined,
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
/**
|
|
421
|
-
* Start the chokidar watcher for a previously-created watch entry and wire its
|
|
422
|
-
* events into coalesced `resourceWatch/changed` notifications. Idempotent: a
|
|
423
|
-
* second subscribe to the same channel is a no-op.
|
|
424
|
-
*
|
|
425
|
-
* Excludes and `.git` are matched via path-relative {@link picomatch}
|
|
426
|
-
* predicates (uniform across platforms); `includes` is applied as a
|
|
427
|
-
* post-filter on emitted paths (omitted = report everything not excluded).
|
|
428
|
-
*/
|
|
429
|
-
function startResourceWatch(conn, channel, entry) {
|
|
430
|
-
if (entry.watcher !== undefined) {
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
// Clear any stopped flag from a prior teardown (e.g. an error-handler tear
|
|
434
|
-
// down followed by a re-subscribe) so the fresh watcher's events are emitted.
|
|
435
|
-
entry.stopped = false;
|
|
436
|
-
const { rootPath, descriptor } = entry;
|
|
437
|
-
const excludeMatchers = (descriptor.excludes?.items ?? []).map((g) => picomatch(g));
|
|
438
|
-
const includeMatchers = (descriptor.includes?.items ?? []).map((g) => picomatch(g));
|
|
439
|
-
const relForMatch = (p) => relativePath(rootPath, p).split(pathSep).join("/");
|
|
440
|
-
// chokidar watches `rootPath`, which is the realpath of the requested URI
|
|
441
|
-
// (assertWithinRoots resolves symlinks). Emit change URIs under the *lexical*
|
|
442
|
-
// root the client asked to watch, so a client that follows a notification
|
|
443
|
-
// with resourceRead passes the lexical sandbox check (allowedRoots holds the
|
|
444
|
-
// lexical working tree, not its realpath). When the root isn't a symlink the
|
|
445
|
-
// two coincide and this is a no-op.
|
|
446
|
-
const lexicalRoot = resourceUriToPath(descriptor.root);
|
|
447
|
-
const emitUriFor = (changedPath) => {
|
|
448
|
-
const rel = relativePath(rootPath, changedPath);
|
|
449
|
-
const lexicalPath = rel === "" ? lexicalRoot : joinPath(lexicalRoot, rel);
|
|
450
|
-
return pathToFileURL(lexicalPath).href;
|
|
451
|
-
};
|
|
452
|
-
const watcher = chokidarWatch(rootPath, {
|
|
453
|
-
ignoreInitial: true,
|
|
454
|
-
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
|
455
|
-
// Do not follow symlinks: a symlinked directory inside the root could
|
|
456
|
-
// otherwise lead the watcher to emit events for paths that live outside
|
|
457
|
-
// the sandbox, bypassing the realpath containment guard enforced by
|
|
458
|
-
// resourceRead/resourceList.
|
|
459
|
-
followSymlinks: false,
|
|
460
|
-
// Always skip .git; apply excludes relative to the watch root.
|
|
461
|
-
ignored: (p) => {
|
|
462
|
-
if (p.split(pathSep).includes(".git")) {
|
|
463
|
-
return true;
|
|
464
|
-
}
|
|
465
|
-
if (excludeMatchers.length === 0) {
|
|
466
|
-
return false;
|
|
467
|
-
}
|
|
468
|
-
const rel = relForMatch(p);
|
|
469
|
-
return rel !== "" && excludeMatchers.some((m) => m(rel));
|
|
470
|
-
},
|
|
471
|
-
// recursive => unlimited depth; non-recursive => only the root's direct
|
|
472
|
-
// entries (depth 0; depth 1 would descend into immediate subdirectories).
|
|
473
|
-
depth: descriptor.recursive ? undefined : 0,
|
|
474
|
-
});
|
|
475
|
-
entry.watcher = watcher;
|
|
476
|
-
const record = (type) => (changedPath) => {
|
|
477
|
-
// A late event delivered after teardown (watcher.close() is async) must
|
|
478
|
-
// not re-arm a timer or emit on the unsubscribed channel.
|
|
479
|
-
if (entry.stopped === true) {
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
if (includeMatchers.length > 0) {
|
|
483
|
-
const rel = relForMatch(changedPath);
|
|
484
|
-
if (rel !== "" && !includeMatchers.some((m) => m(rel))) {
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
const uri = emitUriFor(changedPath);
|
|
489
|
-
// Coalesce against any pending change for this URI: add→delete drops as a
|
|
490
|
-
// net no-op; add→change keeps Added (the client learns the file exists,
|
|
491
|
-
// not that it changed); otherwise latest wins. See coalesceChangeType.
|
|
492
|
-
const next = coalesceChangeType(entry.pending.get(uri)?.type, type);
|
|
493
|
-
if (next === COALESCE_DROP) {
|
|
494
|
-
entry.pending.delete(uri);
|
|
495
|
-
}
|
|
496
|
-
else {
|
|
497
|
-
entry.pending.set(uri, { uri, type: next });
|
|
498
|
-
}
|
|
499
|
-
if (entry.pending.size > 0 && entry.flushTimer === undefined) {
|
|
500
|
-
entry.flushTimer = setTimeout(() => {
|
|
501
|
-
flushResourceWatch(conn, channel, entry);
|
|
502
|
-
}, WATCH_COALESCE_MS);
|
|
503
|
-
}
|
|
504
|
-
};
|
|
505
|
-
watcher.on("add", record(ResourceChangeType.Added));
|
|
506
|
-
watcher.on("addDir", record(ResourceChangeType.Added));
|
|
507
|
-
watcher.on("change", record(ResourceChangeType.Updated));
|
|
508
|
-
watcher.on("unlink", record(ResourceChangeType.Deleted));
|
|
509
|
-
watcher.on("unlinkDir", record(ResourceChangeType.Deleted));
|
|
510
|
-
// FSWatcher is an EventEmitter: an unhandled "error" event would crash the
|
|
511
|
-
// PowerLine process. Log and tear the watch down (the client can recreate it).
|
|
512
|
-
watcher.on("error", (err) => {
|
|
513
|
-
logger.error({ err, channel }, "Resource watch error; tearing down watch");
|
|
514
|
-
stopResourceWatch(entry);
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
/** Release a watch's filesystem resources (watcher + pending flush timer). */
|
|
518
|
-
function stopResourceWatch(entry) {
|
|
519
|
-
// Mark stopped first so any event already queued behind the async close()
|
|
520
|
-
// is ignored by `record`/`flushResourceWatch`.
|
|
521
|
-
entry.stopped = true;
|
|
522
|
-
if (entry.flushTimer !== undefined) {
|
|
523
|
-
clearTimeout(entry.flushTimer);
|
|
524
|
-
entry.flushTimer = undefined;
|
|
525
|
-
}
|
|
526
|
-
entry.pending.clear();
|
|
527
|
-
// Detach our event listeners synchronously (close() is async) so no further
|
|
528
|
-
// events reach `record` even within the close window. Only our own events are
|
|
529
|
-
// removed (not chokidar internals).
|
|
530
|
-
const w = entry.watcher;
|
|
531
|
-
if (w !== undefined) {
|
|
532
|
-
for (const ev of ["add", "addDir", "change", "unlink", "unlinkDir", "error"]) {
|
|
533
|
-
w.removeAllListeners(ev);
|
|
534
|
-
}
|
|
535
|
-
w.close().catch(() => undefined);
|
|
536
|
-
}
|
|
537
|
-
entry.watcher = undefined;
|
|
538
|
-
}
|
|
539
|
-
function handleSubscribe(params, conn) {
|
|
540
|
-
// Resource-watch channels: start the lazily-created watcher and stream
|
|
541
|
-
// change batches. Events-only — the snapshot is omitted (the vendored
|
|
542
|
-
// `Snapshot.state` union does not include `ResourceWatchState`).
|
|
543
|
-
if (params.channel.startsWith(RESOURCE_WATCH_CHANNEL_PREFIX)) {
|
|
544
|
-
const entry = clientState(conn).watches.get(params.channel);
|
|
545
|
-
if (entry === undefined) {
|
|
546
|
-
return {
|
|
547
|
-
jsonrpc: "2.0",
|
|
548
|
-
id: 0,
|
|
549
|
-
error: {
|
|
550
|
-
code: JsonRpcErrorCodes.InvalidParams,
|
|
551
|
-
message: `Unknown resource-watch channel: ${params.channel}`,
|
|
552
|
-
},
|
|
553
|
-
};
|
|
554
|
-
}
|
|
555
|
-
startResourceWatch(conn, params.channel, entry);
|
|
556
|
-
return {
|
|
557
|
-
jsonrpc: "2.0",
|
|
558
|
-
id: 0,
|
|
559
|
-
result: { snapshot: undefined },
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
return handleSessionSubscribe(params, conn);
|
|
563
|
-
}
|
|
564
|
-
function handleSessionSubscribe(params, conn) {
|
|
565
|
-
const sessionId = sessionIdFromChannel(params.channel);
|
|
566
|
-
if (sessionId === undefined) {
|
|
567
|
-
// Subscribing to non-session channels (e.g. ahp-root://) — return an
|
|
568
|
-
// empty SubscribeResult; root state notifications are not implemented.
|
|
569
|
-
return {
|
|
570
|
-
jsonrpc: "2.0",
|
|
571
|
-
id: 0,
|
|
572
|
-
result: { snapshot: undefined },
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
const session = getSession(sessionId);
|
|
576
|
-
if (session === undefined && !isParked(sessionId)) {
|
|
577
|
-
return {
|
|
578
|
-
jsonrpc: "2.0",
|
|
579
|
-
id: 0,
|
|
580
|
-
error: {
|
|
581
|
-
code: JsonRpcErrorCodes.InvalidParams,
|
|
582
|
-
message: `Unknown session channel: ${params.channel}`,
|
|
583
|
-
},
|
|
584
|
-
};
|
|
585
|
-
}
|
|
586
|
-
const cState = clientState(conn);
|
|
587
|
-
// Tear down any prior forwarder for this session (avoid double-forwarding
|
|
588
|
-
// if subscribe is called twice for the same channel). Wake it
|
|
589
|
-
// synchronously so its tail loop notices `cancelled` and exits without
|
|
590
|
-
// waiting on the next pump push.
|
|
591
|
-
const prior = cState.forwarders.get(sessionId);
|
|
592
|
-
if (prior !== undefined) {
|
|
593
|
-
prior.cancelled = true;
|
|
594
|
-
prior.wake?.();
|
|
595
|
-
}
|
|
596
|
-
const forwarder = {
|
|
597
|
-
mapperContext: {
|
|
598
|
-
turnId: undefined,
|
|
599
|
-
openToolCalls: [],
|
|
600
|
-
partCounter: 0,
|
|
601
|
-
eventIndex: 0,
|
|
602
|
-
metaAccumulator: {},
|
|
603
|
-
},
|
|
604
|
-
serverSeq: 0,
|
|
605
|
-
cancelled: false,
|
|
606
|
-
lastMetaSnapshot: undefined,
|
|
607
|
-
pos: 0,
|
|
608
|
-
};
|
|
609
|
-
cState.forwarders.set(sessionId, forwarder);
|
|
610
|
-
// Schedule the parked-event replay + live forwarder to run AFTER the
|
|
611
|
-
// SubscribeResult response is sent. queueMicrotask gives the JSON-RPC
|
|
612
|
-
// layer a chance to flush the response frame first.
|
|
613
|
-
queueMicrotask(() => {
|
|
614
|
-
runForwarder(conn, sessionId, forwarder).catch(() => {
|
|
615
|
-
// Forwarder errors are surfaced via the agent stream's natural
|
|
616
|
-
// failure path; don't unhandled-reject the microtask.
|
|
617
|
-
});
|
|
618
|
-
});
|
|
619
|
-
return {
|
|
620
|
-
jsonrpc: "2.0",
|
|
621
|
-
id: 0,
|
|
622
|
-
result: { snapshot: undefined },
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
async function runForwarder(conn, sessionId, forwarder) {
|
|
626
|
-
// If a rapid resubscribe cancelled us between handleSubscribe's
|
|
627
|
-
// `queueMicrotask` and this microtask actually running, bail before we
|
|
628
|
-
// touch anything — in particular don't drain parked events (the
|
|
629
|
-
// next forwarder needs them) and don't register on the pump (a cancelled
|
|
630
|
-
// forwarder bumping `totalForwardersAttached` would cause the *real*
|
|
631
|
-
// next forwarder to skip the first-subscribe replay and start at the
|
|
632
|
-
// tail, missing the runtime's setup events).
|
|
633
|
-
if (forwarder.cancelled) {
|
|
634
|
-
const cState = clients.get(conn.clientId);
|
|
635
|
-
if (cState?.forwarders.get(sessionId) === forwarder) {
|
|
636
|
-
cState.forwarders.delete(sessionId);
|
|
637
|
-
}
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
// Step 1: drain any parked events first. These are the "what did I miss
|
|
641
|
-
// while disconnected" tail from a prior owner of this channel.
|
|
642
|
-
const parked = drainParkedSession(sessionId);
|
|
643
|
-
if (parked !== undefined) {
|
|
644
|
-
for (const event of parked) {
|
|
645
|
-
// forwarder.cancelled is narrowed to false by the entry-check above,
|
|
646
|
-
// but it's mutated externally by handleSubscribe/disposeSession/
|
|
647
|
-
// onDisconnect — re-check between events so a fast cancellation
|
|
648
|
-
// arriving via an unrelated wire op stops emission mid-stream.
|
|
649
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
650
|
-
if (forwarder.cancelled) {
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
emitActionsForEvent(conn, sessionId, event, forwarder);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
// Step 2: tail the live pump. If the session has no pump (parked-only
|
|
657
|
-
// or already disposed) there's nothing live to forward.
|
|
658
|
-
const pump = getSessionPump(sessionId);
|
|
659
|
-
if (pump === undefined) {
|
|
660
|
-
// Forwarder map cleanup still needs to run.
|
|
661
|
-
const cState = clients.get(conn.clientId);
|
|
662
|
-
if (cState?.forwarders.get(sessionId) === forwarder) {
|
|
663
|
-
cState.forwarders.delete(sessionId);
|
|
664
|
-
}
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
// First-ever forwarder on this pump replays from the buffer's logical
|
|
668
|
-
// start so it observes setup events (`runtime_session_id`, initial system
|
|
669
|
-
// messages) the runtime emits between createSession and subscribe — those
|
|
670
|
-
// are the same wire frames the server's processEventStream needs to write
|
|
671
|
-
// `runtimeSessionId` into the DB row, which `recoverSuspendedSessions`
|
|
672
|
-
// later reads to know if reanimate is even possible. Subsequent
|
|
673
|
-
// forwarders are true mid-stream resubscribes and pick up at the current
|
|
674
|
-
// tail; events missed in a disconnect window arrive via the parked-replay
|
|
675
|
-
// path (Step 1 above), not by replaying the live buffer. `forwarder.pos`
|
|
676
|
-
// is always in the pump's *absolute* event-index space so trims of
|
|
677
|
-
// pump.buffer don't shift its meaning.
|
|
678
|
-
forwarder.pos =
|
|
679
|
-
pump.totalForwardersAttached === 0
|
|
680
|
-
? pump.bufferStartIndex
|
|
681
|
-
: pump.bufferStartIndex + pump.buffer.length;
|
|
682
|
-
registerPumpForwarder(pump, forwarder);
|
|
683
|
-
try {
|
|
684
|
-
// Same TS-narrowing caveat as the parked-replay loop: forwarder.cancelled
|
|
685
|
-
// is mutated by external wire ops and the await below yields control,
|
|
686
|
-
// so the re-check is real even though TS thinks it's always false.
|
|
687
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
688
|
-
while (!forwarder.cancelled) {
|
|
689
|
-
const bufLen = pump.bufferStartIndex + pump.buffer.length;
|
|
690
|
-
while (forwarder.pos < bufLen) {
|
|
691
|
-
const localIdx = forwarder.pos - pump.bufferStartIndex;
|
|
692
|
-
emitActionsForEvent(conn, sessionId, pump.buffer[localIdx], forwarder);
|
|
693
|
-
forwarder.pos++;
|
|
694
|
-
}
|
|
695
|
-
if (pump.done) {
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
// Sleep until the pump pushes another event, or until we're cancelled
|
|
699
|
-
// and woken via `forwarder.wake`. The same `settle` closure is used by
|
|
700
|
-
// both wake paths (pump push via wakePumpWaiters, and external
|
|
701
|
-
// cancellation via forwarder.wake?.()) — it clears forwarder.wake so
|
|
702
|
-
// the field never holds a stale reference between iterations.
|
|
703
|
-
await new Promise((resolve) => {
|
|
704
|
-
const settle = () => {
|
|
705
|
-
forwarder.wake = undefined;
|
|
706
|
-
pump.waiters.delete(settle);
|
|
707
|
-
resolve();
|
|
708
|
-
};
|
|
709
|
-
forwarder.wake = settle;
|
|
710
|
-
pump.waiters.add(settle);
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
finally {
|
|
715
|
-
// Forwarder map cleanup. Session/pump removal happens in one of three
|
|
716
|
-
// places, none of which is here:
|
|
717
|
-
// - `unregisterPumpForwarder` above, on the *last*-forwarder-detach
|
|
718
|
-
// path after `pump.done` (the natural-exit ladder);
|
|
719
|
-
// - `handleDisposeSession`, when the wire explicitly tears down;
|
|
720
|
-
// - `onDisconnect`, when the wire drops and we park the unsent tail.
|
|
721
|
-
// This `finally` only owns the per-(client, session) forwarder map
|
|
722
|
-
// entry — the runtime-level registry is somebody else's job.
|
|
723
|
-
unregisterPumpForwarder(pump, forwarder);
|
|
724
|
-
const cState = clients.get(conn.clientId);
|
|
725
|
-
if (cState?.forwarders.get(sessionId) === forwarder) {
|
|
726
|
-
cState.forwarders.delete(sessionId);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
/**
|
|
731
|
-
* Event types whose mapper-drop ("no active turn") should be rescued by
|
|
732
|
-
* synthesizing an orphan turn-started. These are runtime-emitted content
|
|
733
|
-
* events that, under the gRPC wire, flowed through regardless of turn
|
|
734
|
-
* context. The AHP wire is action-only, so an orphan emission would be
|
|
735
|
-
* silently lost — we patch around that here at the wire boundary.
|
|
736
|
-
*/
|
|
737
|
-
const ORPHAN_RESCUABLE_TYPES = new Set([
|
|
738
|
-
"text",
|
|
739
|
-
"tool_use",
|
|
740
|
-
"tool_result",
|
|
741
|
-
"system",
|
|
742
|
-
]);
|
|
743
|
-
function emitActionsForEvent(conn, sessionId, event, forwarder) {
|
|
744
|
-
const idx = forwarder.mapperContext.eventIndex++;
|
|
745
|
-
// Normalize AgentEvent (with `raw: unknown`) to AgentEventFields
|
|
746
|
-
// (with `raw: string | undefined`) so the mapper signature matches.
|
|
747
|
-
const normalized = {
|
|
748
|
-
type: event.type,
|
|
749
|
-
content: event.content,
|
|
750
|
-
toolCallId: event.toolCallId,
|
|
751
|
-
turnId: event.turnId,
|
|
752
|
-
diagnostic: event.diagnostic,
|
|
753
|
-
toolError: event.toolError,
|
|
754
|
-
timestamp: event.timestamp,
|
|
755
|
-
raw: event.raw !== undefined ? JSON.stringify(event.raw) : undefined,
|
|
756
|
-
};
|
|
757
|
-
// Status rescue (HR8d): mapAgentEvent unconditionally drops status events
|
|
758
|
-
// with content in {running, waiting_input, completed} as "redundant with
|
|
759
|
-
// turn_* events" — but Grackle's consumer uses these to update
|
|
760
|
-
// `sessions.status` (`useSessions.ts` calls `mapSessionStatus(event.content)`).
|
|
761
|
-
// Without them, the UI never observes `latestSession.status === "idle"`
|
|
762
|
-
// and queued chat input never auto-sends. We synthesize a
|
|
763
|
-
// `SessionMetaChangedAction` with `_meta.status` so the consumer's reverse
|
|
764
|
-
// mapper can rehydrate a `status` event with the original content.
|
|
765
|
-
if (event.type === "status" && STATUS_RESCUE_CONTENTS.has(event.content)) {
|
|
766
|
-
forwarder.serverSeq += 1;
|
|
767
|
-
const statusAction = {
|
|
768
|
-
type: ActionType.SessionMetaChanged,
|
|
769
|
-
_meta: { status: event.content },
|
|
770
|
-
};
|
|
771
|
-
conn.session.notify("action", {
|
|
772
|
-
channel: sessionChannel(sessionId),
|
|
773
|
-
serverSeq: forwarder.serverSeq,
|
|
774
|
-
action: statusAction,
|
|
775
|
-
origin: undefined,
|
|
776
|
-
});
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
let result = mapAgentEvent(normalized, idx, forwarder.mapperContext);
|
|
780
|
-
let synthesizedOrphanTurnId;
|
|
781
|
-
// Orphan rescue (HR8d): if the mapper dropped a content event because
|
|
782
|
-
// no turn is active, synthesize a `SessionTurnStarted` and re-run the
|
|
783
|
-
// mapper so the event lands inside a turn. Without this, runtimes that
|
|
784
|
-
// emit text/tool events outside a turn (legitimate under the gRPC wire)
|
|
785
|
-
// would have their content silently dropped on the AHP wire.
|
|
786
|
-
if (result.actions.length === 0 &&
|
|
787
|
-
result.note?.disposition === "dropped" &&
|
|
788
|
-
ORPHAN_RESCUABLE_TYPES.has(normalized.type) &&
|
|
789
|
-
forwarder.mapperContext.turnId === undefined) {
|
|
790
|
-
synthesizedOrphanTurnId = `turn-orphan-${String(idx)}`;
|
|
791
|
-
const startAction = {
|
|
792
|
-
type: ActionType.SessionTurnStarted,
|
|
793
|
-
turnId: synthesizedOrphanTurnId,
|
|
794
|
-
message: { text: "", origin: { kind: MessageKind.User } },
|
|
795
|
-
};
|
|
796
|
-
forwarder.serverSeq += 1;
|
|
797
|
-
conn.session.notify("action", {
|
|
798
|
-
channel: sessionChannel(sessionId),
|
|
799
|
-
serverSeq: forwarder.serverSeq,
|
|
800
|
-
action: startAction,
|
|
801
|
-
origin: undefined,
|
|
802
|
-
});
|
|
803
|
-
// Update context so the re-run sees the synthetic turn as active.
|
|
804
|
-
forwarder.mapperContext.turnId = synthesizedOrphanTurnId;
|
|
805
|
-
// Re-run the mapper with a fresh index. The mapperContext is mutable
|
|
806
|
-
// so the orphan turn is now in scope.
|
|
807
|
-
const reIdx = forwarder.mapperContext.eventIndex++;
|
|
808
|
-
result = mapAgentEvent(normalized, reIdx, forwarder.mapperContext);
|
|
809
|
-
}
|
|
810
|
-
for (const action of result.actions) {
|
|
811
|
-
forwarder.serverSeq += 1;
|
|
812
|
-
conn.session.notify("action", {
|
|
813
|
-
channel: sessionChannel(sessionId),
|
|
814
|
-
serverSeq: forwarder.serverSeq,
|
|
815
|
-
action,
|
|
816
|
-
origin: undefined,
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
// Close the synthetic orphan turn after the rescued event lands. This
|
|
820
|
-
// matters for the consumer's UI grouping: an open turn with no `turn_complete`
|
|
821
|
-
// may be held back until completion. Each orphan event gets its own
|
|
822
|
-
// single-content synthetic turn that opens and closes immediately.
|
|
823
|
-
if (synthesizedOrphanTurnId !== undefined) {
|
|
824
|
-
const completeAction = {
|
|
825
|
-
type: ActionType.SessionTurnComplete,
|
|
826
|
-
turnId: synthesizedOrphanTurnId,
|
|
827
|
-
};
|
|
828
|
-
forwarder.serverSeq += 1;
|
|
829
|
-
conn.session.notify("action", {
|
|
830
|
-
channel: sessionChannel(sessionId),
|
|
831
|
-
serverSeq: forwarder.serverSeq,
|
|
832
|
-
action: completeAction,
|
|
833
|
-
origin: undefined,
|
|
834
|
-
});
|
|
835
|
-
// The mapper sets context.turnId = undefined inside its
|
|
836
|
-
// SessionTurnComplete case, but we manage the context for the wire
|
|
837
|
-
// forwarder ourselves — clear it now so any subsequent real turn
|
|
838
|
-
// can claim the active-turn slot cleanly.
|
|
839
|
-
forwarder.mapperContext.turnId = undefined;
|
|
840
|
-
}
|
|
841
|
-
// Also synthesize a SessionMetaChangedAction whenever the meta
|
|
842
|
-
// accumulator advances, so the consumer's reverse mapper can rehydrate
|
|
843
|
-
// `usage` / `runtime_session_id` events.
|
|
844
|
-
//
|
|
845
|
-
// Detect "advanced" by comparing against the last snapshot we emitted, not
|
|
846
|
-
// by `result.note?.disposition === "carried"`. The mapper returns
|
|
847
|
-
// `carried` for several non-meta cases too (diagnostic system events,
|
|
848
|
-
// runtime_session_id with no content, etc.), so a disposition check alone
|
|
849
|
-
// re-emits the same `_meta` payload on every carried event and floods the
|
|
850
|
-
// wire. Comparing snapshots makes the emit truly edge-triggered.
|
|
851
|
-
const metaSnapshot = {};
|
|
852
|
-
if (forwarder.mapperContext.metaAccumulator.runtimeSessionId !== undefined) {
|
|
853
|
-
metaSnapshot.runtime_session_id = forwarder.mapperContext.metaAccumulator.runtimeSessionId;
|
|
854
|
-
}
|
|
855
|
-
if (forwarder.mapperContext.metaAccumulator.costMillicents !== undefined) {
|
|
856
|
-
metaSnapshot.cost_millicents = forwarder.mapperContext.metaAccumulator.costMillicents;
|
|
857
|
-
}
|
|
858
|
-
// HR8d follow-up #1355: carry token totals alongside cost.
|
|
859
|
-
if (forwarder.mapperContext.metaAccumulator.inputTokens !== undefined) {
|
|
860
|
-
metaSnapshot.input_tokens = forwarder.mapperContext.metaAccumulator.inputTokens;
|
|
861
|
-
}
|
|
862
|
-
if (forwarder.mapperContext.metaAccumulator.outputTokens !== undefined) {
|
|
863
|
-
metaSnapshot.output_tokens = forwarder.mapperContext.metaAccumulator.outputTokens;
|
|
864
|
-
}
|
|
865
|
-
if (Object.keys(metaSnapshot).length > 0 &&
|
|
866
|
-
!shallowEqualSnapshots(forwarder.lastMetaSnapshot, metaSnapshot)) {
|
|
867
|
-
forwarder.serverSeq += 1;
|
|
868
|
-
const metaAction = {
|
|
869
|
-
type: ActionType.SessionMetaChanged,
|
|
870
|
-
_meta: metaSnapshot,
|
|
871
|
-
};
|
|
872
|
-
conn.session.notify("action", {
|
|
873
|
-
channel: sessionChannel(sessionId),
|
|
874
|
-
serverSeq: forwarder.serverSeq,
|
|
875
|
-
action: metaAction,
|
|
876
|
-
origin: undefined,
|
|
877
|
-
});
|
|
878
|
-
forwarder.lastMetaSnapshot = metaSnapshot;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
/** Shallow-equality check for `_meta` snapshot dedup in the forwarder. */
|
|
882
|
-
function shallowEqualSnapshots(a, b) {
|
|
883
|
-
if (a === undefined) {
|
|
884
|
-
return false;
|
|
885
|
-
}
|
|
886
|
-
const aKeys = Object.keys(a);
|
|
887
|
-
const bKeys = Object.keys(b);
|
|
888
|
-
if (aKeys.length !== bKeys.length) {
|
|
889
|
-
return false;
|
|
890
|
-
}
|
|
891
|
-
for (const k of aKeys) {
|
|
892
|
-
if (a[k] !== b[k]) {
|
|
893
|
-
return false;
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
return true;
|
|
897
|
-
}
|
|
898
|
-
function handleDispatchAction(params, _conn) {
|
|
899
|
-
const sessionId = sessionIdFromChannel(params.channel);
|
|
900
|
-
if (sessionId === undefined) {
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
const session = getSession(sessionId);
|
|
904
|
-
if (session === undefined) {
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
// Only SessionTurnStartedAction maps to Grackle's `sendInput` semantics.
|
|
908
|
-
if (params.action.type === ActionType.SessionTurnStarted) {
|
|
909
|
-
const a = params.action;
|
|
910
|
-
session.sendInput(a.message.text);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
function handleDisposeSession(params, conn) {
|
|
914
|
-
const sessionId = sessionIdFromChannel(params.channel);
|
|
915
|
-
if (sessionId === undefined) {
|
|
916
|
-
return {
|
|
917
|
-
jsonrpc: "2.0",
|
|
918
|
-
id: 0,
|
|
919
|
-
error: {
|
|
920
|
-
code: JsonRpcErrorCodes.InvalidParams,
|
|
921
|
-
message: "disposeSession: channel must be ahp-session:/<id>",
|
|
922
|
-
},
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
const session = getSession(sessionId);
|
|
926
|
-
if (session !== undefined) {
|
|
927
|
-
session.kill("killed");
|
|
928
|
-
// Synchronous removal so the caller sees the session gone immediately
|
|
929
|
-
// on return. The pump's natural-exit cleanup is idempotent — it won't
|
|
930
|
-
// re-remove what's already been removed.
|
|
931
|
-
removeSession(sessionId);
|
|
932
|
-
deleteSessionPump(sessionId);
|
|
933
|
-
}
|
|
934
|
-
const cState = clients.get(conn.clientId);
|
|
935
|
-
if (cState !== undefined) {
|
|
936
|
-
cState.sessionIds.delete(sessionId);
|
|
937
|
-
const fwd = cState.forwarders.get(sessionId);
|
|
938
|
-
if (fwd !== undefined) {
|
|
939
|
-
// Synthesize a terminal `killed` status as the LAST action on the wire
|
|
940
|
-
// before tearing down the forwarder (#1356). The runtime's abort can
|
|
941
|
-
// emit a trailing synthetic `waiting_input`; if that were the final
|
|
942
|
-
// forwarded event the UI would believe the killed session is still
|
|
943
|
-
// alive. Emitting `killed` here and immediately cancelling the
|
|
944
|
-
// forwarder guarantees the session's terminal state is what the
|
|
945
|
-
// consumer sees last. Mirrors the status-rescue block above.
|
|
946
|
-
fwd.serverSeq += 1;
|
|
947
|
-
const killedAction = {
|
|
948
|
-
type: ActionType.SessionMetaChanged,
|
|
949
|
-
_meta: { status: "killed" },
|
|
950
|
-
};
|
|
951
|
-
conn.session.notify("action", {
|
|
952
|
-
channel: sessionChannel(sessionId),
|
|
953
|
-
serverSeq: fwd.serverSeq,
|
|
954
|
-
action: killedAction,
|
|
955
|
-
origin: undefined,
|
|
956
|
-
});
|
|
957
|
-
fwd.cancelled = true;
|
|
958
|
-
fwd.wake?.();
|
|
959
|
-
cState.forwarders.delete(sessionId);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
return {
|
|
963
|
-
jsonrpc: "2.0",
|
|
964
|
-
id: 0,
|
|
965
|
-
result: null,
|
|
966
|
-
};
|
|
967
|
-
}
|
|
968
|
-
function handleListSessions(_params) {
|
|
969
|
-
const items = listAllSessions().map((s) => {
|
|
970
|
-
const now = Date.now();
|
|
971
|
-
return {
|
|
972
|
-
resource: sessionChannel(s.id),
|
|
973
|
-
provider: s.runtimeName,
|
|
974
|
-
title: s.id,
|
|
975
|
-
// Map PowerLine's loose status string to AHP's bitset enum
|
|
976
|
-
// best-effort. Unknown statuses become Idle.
|
|
977
|
-
status: mapAgentStatusToAhp(s.status),
|
|
978
|
-
createdAt: now,
|
|
979
|
-
modifiedAt: now,
|
|
980
|
-
};
|
|
981
|
-
});
|
|
982
|
-
return { items };
|
|
983
|
-
}
|
|
984
|
-
function mapAgentStatusToAhp(status) {
|
|
985
|
-
switch (status) {
|
|
986
|
-
// SessionStatus values (from session.status field)
|
|
987
|
-
case "pending":
|
|
988
|
-
return SessionStatus.InProgress;
|
|
989
|
-
case "running":
|
|
990
|
-
return SessionStatus.InProgress;
|
|
991
|
-
case "idle":
|
|
992
|
-
return SessionStatus.InputNeeded;
|
|
993
|
-
case "stopped":
|
|
994
|
-
return SessionStatus.Idle;
|
|
995
|
-
case "suspended":
|
|
996
|
-
return SessionStatus.Idle;
|
|
997
|
-
// Status event content strings (from event stream)
|
|
998
|
-
case "waiting_input":
|
|
999
|
-
return SessionStatus.InputNeeded;
|
|
1000
|
-
case "completed":
|
|
1001
|
-
return SessionStatus.Idle;
|
|
1002
|
-
case "failed":
|
|
1003
|
-
case "killed":
|
|
1004
|
-
case "terminated":
|
|
1005
|
-
return SessionStatus.Error;
|
|
1006
|
-
default:
|
|
1007
|
-
return SessionStatus.Idle;
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
async function handleAuthenticate(params) {
|
|
1011
|
-
// Grackle field-abuse encoding (per #1336):
|
|
1012
|
-
// - resource: `grackle://provider/{provider}/{name}`
|
|
1013
|
-
// - token: JSON-encoded { type, envVar?, filePath?, value }
|
|
1014
|
-
const match = /^grackle:\/\/provider\/([^/]+)\/(.+)$/.exec(params.resource);
|
|
1015
|
-
if (match === null) {
|
|
1016
|
-
return { _error: `Unrecognized authenticate resource: ${params.resource}` };
|
|
1017
|
-
}
|
|
1018
|
-
const [, , name] = match;
|
|
1019
|
-
let parsed;
|
|
1020
|
-
try {
|
|
1021
|
-
parsed = JSON.parse(params.token);
|
|
1022
|
-
}
|
|
1023
|
-
catch {
|
|
1024
|
-
return { _error: "authenticate.token must be JSON-encoded credential" };
|
|
1025
|
-
}
|
|
1026
|
-
await writeTokens([
|
|
1027
|
-
{
|
|
1028
|
-
name: name,
|
|
1029
|
-
type: parsed.type,
|
|
1030
|
-
envVar: parsed.envVar ?? "",
|
|
1031
|
-
filePath: parsed.filePath ?? "",
|
|
1032
|
-
value: parsed.value,
|
|
1033
|
-
},
|
|
1034
|
-
]);
|
|
1035
|
-
return {};
|
|
1036
|
-
}
|
|
1037
|
-
function handlePing(_params) {
|
|
1038
|
-
return null;
|
|
1039
|
-
}
|
|
1040
54
|
// ─── AhpServerSocket wiring ───────────────────────────────────
|
|
1041
55
|
const ahp = new AhpServerSocket({
|
|
1042
56
|
server: opts.server,
|
|
@@ -1047,18 +61,18 @@ export function mountAhpServer(opts) {
|
|
|
1047
61
|
const method = req.method;
|
|
1048
62
|
switch (method) {
|
|
1049
63
|
case "createSession": {
|
|
1050
|
-
const resp = handleCreateSession(req.params, conn);
|
|
64
|
+
const resp = handleCreateSession(req.params, conn, clientState(conn), clients);
|
|
1051
65
|
if (resp !== undefined) {
|
|
1052
66
|
return { ...resp, id: req.id };
|
|
1053
67
|
}
|
|
1054
68
|
return jsonRpcSuccess(req, null);
|
|
1055
69
|
}
|
|
1056
70
|
case "subscribe": {
|
|
1057
|
-
const resp = handleSubscribe(req.params, conn);
|
|
71
|
+
const resp = handleSubscribe(req.params, conn, clientState(conn), clients);
|
|
1058
72
|
return { ...resp, id: req.id };
|
|
1059
73
|
}
|
|
1060
74
|
case "disposeSession": {
|
|
1061
|
-
const resp = handleDisposeSession(req.params, conn);
|
|
75
|
+
const resp = handleDisposeSession(req.params, conn, clients);
|
|
1062
76
|
return { ...resp, id: req.id };
|
|
1063
77
|
}
|
|
1064
78
|
case "listSessions":
|
|
@@ -1071,7 +85,7 @@ export function mountAhpServer(opts) {
|
|
|
1071
85
|
return jsonRpcSuccess(req, result);
|
|
1072
86
|
}
|
|
1073
87
|
case "ping":
|
|
1074
|
-
return jsonRpcSuccess(req,
|
|
88
|
+
return jsonRpcSuccess(req, null);
|
|
1075
89
|
case "resourceRead": {
|
|
1076
90
|
const p = req.params;
|
|
1077
91
|
try {
|
|
@@ -1092,7 +106,7 @@ export function mountAhpServer(opts) {
|
|
|
1092
106
|
}
|
|
1093
107
|
case "createResourceWatch": {
|
|
1094
108
|
try {
|
|
1095
|
-
return jsonRpcSuccess(req, await createResourceWatchEntry(req.params, conn));
|
|
109
|
+
return jsonRpcSuccess(req, await createResourceWatchEntry(req.params, clientState(conn)));
|
|
1096
110
|
}
|
|
1097
111
|
catch (err) {
|
|
1098
112
|
return resourceErrorToResponse(req, err);
|
|
@@ -1104,12 +118,10 @@ export function mountAhpServer(opts) {
|
|
|
1104
118
|
},
|
|
1105
119
|
onNotification: (notif, conn) => {
|
|
1106
120
|
if (notif.method === "dispatchAction") {
|
|
1107
|
-
handleDispatchAction(notif.params
|
|
121
|
+
handleDispatchAction(notif.params);
|
|
1108
122
|
return;
|
|
1109
123
|
}
|
|
1110
124
|
if (notif.method === "unsubscribe") {
|
|
1111
|
-
// Releasing a resource-watch subscription closes its watcher (the watch
|
|
1112
|
-
// lifecycle is tied to subscription — there is no dispose command).
|
|
1113
125
|
const channel = notif.params.channel;
|
|
1114
126
|
if (channel?.startsWith(RESOURCE_WATCH_CHANNEL_PREFIX) === true) {
|
|
1115
127
|
const cState = clients.get(conn.clientId);
|
|
@@ -1121,8 +133,6 @@ export function mountAhpServer(opts) {
|
|
|
1121
133
|
}
|
|
1122
134
|
}
|
|
1123
135
|
}
|
|
1124
|
-
// Session-channel unsubscribe stays a no-op (the forwarder is torn down
|
|
1125
|
-
// on resubscribe / dispose / disconnect).
|
|
1126
136
|
}
|
|
1127
137
|
},
|
|
1128
138
|
onDisconnect: (clientId) => {
|
|
@@ -1130,15 +140,6 @@ export function mountAhpServer(opts) {
|
|
|
1130
140
|
if (cState === undefined) {
|
|
1131
141
|
return;
|
|
1132
142
|
}
|
|
1133
|
-
// For each session this client owned, kill + park its unsent events for
|
|
1134
|
-
// replay on next subscribe (whoever calls subscribe next, including
|
|
1135
|
-
// a reconnecting same-client).
|
|
1136
|
-
//
|
|
1137
|
-
// The "unsent tail" is the slice of `pump.buffer` past the forwarder's
|
|
1138
|
-
// position, concatenated with anything still in the runtime's own
|
|
1139
|
-
// queue that the pump hasn't yet pulled. `session.kill()` is sync and
|
|
1140
|
-
// closes the runtime queue; the pump task's natural exit (a microtask
|
|
1141
|
-
// later) is idempotent — see `runPump`'s finally.
|
|
1142
143
|
for (const sessionId of cState.sessionIds) {
|
|
1143
144
|
const session = getSession(sessionId);
|
|
1144
145
|
const pump = getSessionPump(sessionId);
|
|
@@ -1146,9 +147,6 @@ export function mountAhpServer(opts) {
|
|
|
1146
147
|
if (session !== undefined && pump !== undefined) {
|
|
1147
148
|
session.kill("disconnected");
|
|
1148
149
|
const stillInRuntimeQueue = session.drainBufferedEvents();
|
|
1149
|
-
// Translate the forwarder's absolute pos into the local buffer
|
|
1150
|
-
// slice. If there's no forwarder, start at the buffer's logical
|
|
1151
|
-
// start so we capture every event the pump has read.
|
|
1152
150
|
const fromAbs = fwd?.pos ?? pump.bufferStartIndex;
|
|
1153
151
|
const localStart = Math.max(0, fromAbs - pump.bufferStartIndex);
|
|
1154
152
|
const tail = [...pump.buffer.slice(localStart), ...stillInRuntimeQueue];
|
|
@@ -1163,7 +161,6 @@ export function mountAhpServer(opts) {
|
|
|
1163
161
|
fwd.wake?.();
|
|
1164
162
|
}
|
|
1165
163
|
}
|
|
1166
|
-
// Release any filesystem watches this client held.
|
|
1167
164
|
for (const entry of cState.watches.values()) {
|
|
1168
165
|
stopResourceWatch(entry);
|
|
1169
166
|
}
|