@grackle-ai/powerline 0.170.0 → 0.171.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.
Files changed (39) hide show
  1. package/dist/ahp-handlers.d.ts +7 -44
  2. package/dist/ahp-handlers.d.ts.map +1 -1
  3. package/dist/ahp-handlers.js +21 -1024
  4. package/dist/ahp-handlers.js.map +1 -1
  5. package/dist/ahp-types.d.ts +92 -0
  6. package/dist/ahp-types.d.ts.map +1 -0
  7. package/dist/ahp-types.js +19 -0
  8. package/dist/ahp-types.js.map +1 -0
  9. package/dist/channel-codec.d.ts +31 -0
  10. package/dist/channel-codec.d.ts.map +1 -0
  11. package/dist/channel-codec.js +38 -0
  12. package/dist/channel-codec.js.map +1 -0
  13. package/dist/forwarder.d.ts +26 -0
  14. package/dist/forwarder.d.ts.map +1 -0
  15. package/dist/forwarder.js +223 -0
  16. package/dist/forwarder.js.map +1 -0
  17. package/dist/handlers/action-handlers.d.ts +12 -0
  18. package/dist/handlers/action-handlers.d.ts.map +1 -0
  19. package/dist/handlers/action-handlers.js +49 -0
  20. package/dist/handlers/action-handlers.js.map +1 -0
  21. package/dist/handlers/session-handlers.d.ts +16 -0
  22. package/dist/handlers/session-handlers.d.ts.map +1 -0
  23. package/dist/handlers/session-handlers.js +262 -0
  24. package/dist/handlers/session-handlers.js.map +1 -0
  25. package/dist/handlers/subscribe-handlers.d.ts +10 -0
  26. package/dist/handlers/subscribe-handlers.d.ts.map +1 -0
  27. package/dist/handlers/subscribe-handlers.js +82 -0
  28. package/dist/handlers/subscribe-handlers.js.map +1 -0
  29. package/dist/index.js +11 -25
  30. package/dist/index.js.map +1 -1
  31. package/dist/resource-watch.d.ts +30 -0
  32. package/dist/resource-watch.d.ts.map +1 -0
  33. package/dist/resource-watch.js +169 -0
  34. package/dist/resource-watch.js.map +1 -0
  35. package/dist/runtime-loader.d.ts +9 -0
  36. package/dist/runtime-loader.d.ts.map +1 -0
  37. package/dist/runtime-loader.js +69 -0
  38. package/dist/runtime-loader.js.map +1 -0
  39. package/package.json +10 -10
@@ -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 existing
6
- * PowerLine session machinery (runtime registry, session-mgr,
7
- * token-writer).
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 { ActionType, AhpErrorCodes, JsonRpcErrorCodes, MessageKind, ResourceChangeType, SessionStatus, } from "@grackle-ai/ahp";
43
- import { AhpServerSocket, } from "@grackle-ai/ahp-transport";
44
- import { mapAgentEvent } from "@grackle-ai/common";
45
- import { validateGitBranchName, worktreeDir } from "@grackle-ai/runtime-sdk";
46
- import { watch as chokidarWatch } from "chokidar";
47
- import picomatch from "picomatch";
48
- import { existsSync } from "node:fs";
49
- import { randomUUID } from "node:crypto";
50
- import { join as joinPath, relative as relativePath, resolve as resolvePath, sep as pathSep, } from "node:path";
51
- import { pathToFileURL } from "node:url";
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
- let state = clients.get(conn.clientId);
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, handlePing(req.params));
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, conn);
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
  }