pi-app-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/command-classification.d.ts +59 -0
  4. package/dist/command-classification.d.ts.map +1 -0
  5. package/dist/command-classification.js +78 -0
  6. package/dist/command-classification.js.map +7 -0
  7. package/dist/command-execution-engine.d.ts +118 -0
  8. package/dist/command-execution-engine.d.ts.map +1 -0
  9. package/dist/command-execution-engine.js +259 -0
  10. package/dist/command-execution-engine.js.map +7 -0
  11. package/dist/command-replay-store.d.ts +241 -0
  12. package/dist/command-replay-store.d.ts.map +1 -0
  13. package/dist/command-replay-store.js +306 -0
  14. package/dist/command-replay-store.js.map +7 -0
  15. package/dist/command-router.d.ts +25 -0
  16. package/dist/command-router.d.ts.map +1 -0
  17. package/dist/command-router.js +353 -0
  18. package/dist/command-router.js.map +7 -0
  19. package/dist/extension-ui.d.ts +139 -0
  20. package/dist/extension-ui.d.ts.map +1 -0
  21. package/dist/extension-ui.js +189 -0
  22. package/dist/extension-ui.js.map +7 -0
  23. package/dist/resource-governor.d.ts +254 -0
  24. package/dist/resource-governor.d.ts.map +1 -0
  25. package/dist/resource-governor.js +603 -0
  26. package/dist/resource-governor.js.map +7 -0
  27. package/dist/server-command-handlers.d.ts +120 -0
  28. package/dist/server-command-handlers.d.ts.map +1 -0
  29. package/dist/server-command-handlers.js +234 -0
  30. package/dist/server-command-handlers.js.map +7 -0
  31. package/dist/server-ui-context.d.ts +22 -0
  32. package/dist/server-ui-context.d.ts.map +1 -0
  33. package/dist/server-ui-context.js +221 -0
  34. package/dist/server-ui-context.js.map +7 -0
  35. package/dist/server.d.ts +82 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +561 -0
  38. package/dist/server.js.map +7 -0
  39. package/dist/session-lock-manager.d.ts +100 -0
  40. package/dist/session-lock-manager.d.ts.map +1 -0
  41. package/dist/session-lock-manager.js +199 -0
  42. package/dist/session-lock-manager.js.map +7 -0
  43. package/dist/session-manager.d.ts +196 -0
  44. package/dist/session-manager.d.ts.map +1 -0
  45. package/dist/session-manager.js +1010 -0
  46. package/dist/session-manager.js.map +7 -0
  47. package/dist/session-store.d.ts +190 -0
  48. package/dist/session-store.d.ts.map +1 -0
  49. package/dist/session-store.js +446 -0
  50. package/dist/session-store.js.map +7 -0
  51. package/dist/session-version-store.d.ts +83 -0
  52. package/dist/session-version-store.d.ts.map +1 -0
  53. package/dist/session-version-store.js +117 -0
  54. package/dist/session-version-store.js.map +7 -0
  55. package/dist/type-guards.d.ts +59 -0
  56. package/dist/type-guards.d.ts.map +1 -0
  57. package/dist/type-guards.js +40 -0
  58. package/dist/type-guards.js.map +7 -0
  59. package/dist/types.d.ts +621 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +23 -0
  62. package/dist/types.js.map +7 -0
  63. package/dist/validation.d.ts +22 -0
  64. package/dist/validation.d.ts.map +1 -0
  65. package/dist/validation.js +323 -0
  66. package/dist/validation.js.map +7 -0
  67. package/package.json +135 -0
@@ -0,0 +1,1010 @@
1
+ import {
2
+ createAgentSession
3
+ } from "@mariozechner/pi-coding-agent";
4
+ import {
5
+ getCommandDependsOn,
6
+ getCommandId,
7
+ getCommandIdempotencyKey,
8
+ getCommandIfSessionVersion,
9
+ getCommandType,
10
+ getSessionId
11
+ } from "./types.js";
12
+ import { routeSessionCommand } from "./command-router.js";
13
+ import {
14
+ routeServerCommand,
15
+ executeLLMCommand,
16
+ executeBashCommand
17
+ } from "./server-command-handlers.js";
18
+ import { ExtensionUIManager } from "./extension-ui.js";
19
+ import { createServerUIContext } from "./server-ui-context.js";
20
+ import { validateCommand, formatValidationErrors } from "./validation.js";
21
+ import { ResourceGovernor, DEFAULT_CONFIG } from "./resource-governor.js";
22
+ import {
23
+ CommandReplayStore,
24
+ SYNTHETIC_ID_PREFIX
25
+ } from "./command-replay-store.js";
26
+ import { SessionVersionStore } from "./session-version-store.js";
27
+ import { CommandExecutionEngine } from "./command-execution-engine.js";
28
+ import { SessionLockManager } from "./session-lock-manager.js";
29
+ import { SessionStore } from "./session-store.js";
30
+ import { CircuitBreakerManager } from "./circuit-breaker.js";
31
+ import { BashCircuitBreaker } from "./bash-circuit-breaker.js";
32
+ const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60 * 1e3;
33
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30 * 1e3;
34
+ const SHORT_COMMAND_TIMEOUT_MS = 30 * 1e3;
35
+ const DEPENDENCY_WAIT_TIMEOUT_MS = 30 * 1e3;
36
+ const SANITIZED_NPM_ENV_KEYS = ["npm_config_prefix", "NPM_CONFIG_PREFIX"];
37
+ class PiSessionManager {
38
+ sessions = /* @__PURE__ */ new Map();
39
+ sessionCreatedAt = /* @__PURE__ */ new Map();
40
+ subscribers = /* @__PURE__ */ new Set();
41
+ unsubscribers = /* @__PURE__ */ new Map();
42
+ governor;
43
+ /** Command replay and idempotency store. */
44
+ replayStore;
45
+ /** Session version store. */
46
+ versionStore;
47
+ /** Command execution engine. */
48
+ executionEngine;
49
+ /** Session ID lock manager for preventing create/delete races. */
50
+ lockManager;
51
+ /** Session metadata store for persistence across restarts (ADR-0007). */
52
+ sessionStore;
53
+ /** Circuit breaker for LLM providers (ADR-0010). */
54
+ circuitBreakers;
55
+ /** Circuit breaker for bash commands. */
56
+ bashCircuitBreaker;
57
+ // Shutdown state (single source of truth - server.ts delegates to this)
58
+ isShuttingDown = false;
59
+ inFlightCommands = /* @__PURE__ */ new Set();
60
+ // Periodic cleanup timers
61
+ sessionExpirationTimer = null;
62
+ defaultCommandTimeoutMs;
63
+ shortCommandTimeoutMs;
64
+ dependencyWaitTimeoutMs;
65
+ // Extension UI request tracking
66
+ extensionUI = new ExtensionUIManager(
67
+ (sessionId, event) => this.broadcastEvent(sessionId, event)
68
+ );
69
+ /** Optional memory metrics provider (set by server for ADR-0016) */
70
+ memoryMetricsProvider = null;
71
+ constructor(governor, options = {}) {
72
+ this.governor = governor ?? new ResourceGovernor(DEFAULT_CONFIG);
73
+ this.defaultCommandTimeoutMs = typeof options.defaultCommandTimeoutMs === "number" && options.defaultCommandTimeoutMs > 0 ? options.defaultCommandTimeoutMs : DEFAULT_COMMAND_TIMEOUT_MS;
74
+ this.shortCommandTimeoutMs = typeof options.shortCommandTimeoutMs === "number" && options.shortCommandTimeoutMs > 0 ? options.shortCommandTimeoutMs : SHORT_COMMAND_TIMEOUT_MS;
75
+ this.dependencyWaitTimeoutMs = typeof options.dependencyWaitTimeoutMs === "number" && options.dependencyWaitTimeoutMs > 0 ? options.dependencyWaitTimeoutMs : DEPENDENCY_WAIT_TIMEOUT_MS;
76
+ this.replayStore = new CommandReplayStore({
77
+ idempotencyTtlMs: options.idempotencyTtlMs
78
+ });
79
+ this.versionStore = new SessionVersionStore();
80
+ this.executionEngine = new CommandExecutionEngine(
81
+ this.replayStore,
82
+ this.versionStore,
83
+ this,
84
+ // SessionResolver - the NEXUS seam
85
+ {
86
+ defaultCommandTimeoutMs: this.defaultCommandTimeoutMs,
87
+ shortCommandTimeoutMs: this.shortCommandTimeoutMs,
88
+ dependencyWaitTimeoutMs: this.dependencyWaitTimeoutMs
89
+ }
90
+ );
91
+ this.lockManager = new SessionLockManager();
92
+ this.sessionStore = new SessionStore({
93
+ serverVersion: options.serverVersion
94
+ });
95
+ this.circuitBreakers = new CircuitBreakerManager(options.circuitBreakerConfig);
96
+ this.bashCircuitBreaker = new BashCircuitBreaker(options.bashCircuitBreakerConfig);
97
+ }
98
+ /**
99
+ * Get the resource governor for external checks (e.g., message size).
100
+ */
101
+ getGovernor() {
102
+ return this.governor;
103
+ }
104
+ /**
105
+ * Get the circuit breaker manager for external access (e.g., admin operations).
106
+ */
107
+ getCircuitBreakers() {
108
+ return this.circuitBreakers;
109
+ }
110
+ /**
111
+ * Get the bash circuit breaker for external access.
112
+ */
113
+ getBashCircuitBreaker() {
114
+ return this.bashCircuitBreaker;
115
+ }
116
+ // ==========================================================================
117
+ // SHUTDOWN MANAGEMENT
118
+ // ==========================================================================
119
+ /**
120
+ * Check if the server is shutting down.
121
+ */
122
+ isInShutdown() {
123
+ return this.isShuttingDown;
124
+ }
125
+ /**
126
+ * Set the memory metrics provider for ADR-0016 metrics system.
127
+ * Called by PiServer to provide access to MemorySink metrics.
128
+ */
129
+ setMemoryMetricsProvider(provider) {
130
+ this.memoryMetricsProvider = provider;
131
+ }
132
+ /**
133
+ * Initiate graceful shutdown.
134
+ * - Stops accepting new commands
135
+ * - Broadcasts shutdown notification to all clients
136
+ * - Returns promise that resolves when all in-flight commands complete or timeout
137
+ *
138
+ * Idempotent: calling multiple times returns the same result.
139
+ */
140
+ async initiateShutdown(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
141
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
142
+ timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS;
143
+ }
144
+ if (this.isShuttingDown) {
145
+ const remaining = this.inFlightCommands.size;
146
+ return { drained: 0, timedOut: remaining > 0 };
147
+ }
148
+ this.isShuttingDown = true;
149
+ const shutdownEvent = {
150
+ type: "server_shutdown",
151
+ data: { reason: "graceful_shutdown", timeoutMs }
152
+ };
153
+ this.broadcast(JSON.stringify(shutdownEvent));
154
+ const inFlightCount = this.inFlightCommands.size;
155
+ if (inFlightCount === 0) {
156
+ return { drained: 0, timedOut: false };
157
+ }
158
+ const snapshot = [...this.inFlightCommands];
159
+ const drainPromise = Promise.allSettled(snapshot);
160
+ const timeoutPromise = new Promise((resolve) => {
161
+ setTimeout(() => {
162
+ const stillPending = snapshot.filter((p) => this.inFlightCommands.has(p)).length;
163
+ const drained = inFlightCount - stillPending;
164
+ resolve({ drained, timedOut: true });
165
+ }, timeoutMs);
166
+ });
167
+ const drainResult = new Promise((resolve) => {
168
+ drainPromise.then(() => {
169
+ resolve({ drained: inFlightCount, timedOut: false });
170
+ });
171
+ });
172
+ return Promise.race([drainResult, timeoutPromise]);
173
+ }
174
+ /**
175
+ * Dispose all sessions. Call after shutdown drain completes.
176
+ */
177
+ disposeAllSessions() {
178
+ let disposed = 0;
179
+ let failed = 0;
180
+ const sessionIds = [...this.sessions.keys()];
181
+ for (const sessionId of sessionIds) {
182
+ try {
183
+ const session = this.sessions.get(sessionId);
184
+ this.sessions.delete(sessionId);
185
+ this.sessionCreatedAt.delete(sessionId);
186
+ const unsubscribe = this.unsubscribers.get(sessionId);
187
+ if (unsubscribe) {
188
+ this.unsubscribers.delete(sessionId);
189
+ try {
190
+ unsubscribe();
191
+ } catch {
192
+ }
193
+ }
194
+ if (session) {
195
+ try {
196
+ session.dispose();
197
+ disposed++;
198
+ } catch {
199
+ failed++;
200
+ }
201
+ }
202
+ } catch {
203
+ failed++;
204
+ }
205
+ }
206
+ this.versionStore.clear();
207
+ this.executionEngine.clear();
208
+ this.replayStore.clear();
209
+ this.lockManager.clear();
210
+ this.governor.cleanupStaleData(/* @__PURE__ */ new Set());
211
+ return { disposed, failed };
212
+ }
213
+ /**
214
+ * Get count of in-flight commands.
215
+ */
216
+ getInFlightCount() {
217
+ return this.inFlightCommands.size;
218
+ }
219
+ /**
220
+ * Register an in-flight command promise for shutdown draining.
221
+ */
222
+ registerInFlightCommand(promise) {
223
+ this.inFlightCommands.add(promise);
224
+ const cleanup = () => {
225
+ this.inFlightCommands.delete(promise);
226
+ };
227
+ promise.then(cleanup, cleanup);
228
+ }
229
+ broadcastCommandLifecycle(phase, data) {
230
+ const event = {
231
+ type: phase,
232
+ data
233
+ };
234
+ this.broadcast(JSON.stringify(event));
235
+ }
236
+ // ==========================================================================
237
+ // SESSION LIFECYCLE
238
+ // ==========================================================================
239
+ async createSession(sessionId, cwd) {
240
+ const sessionIdError = this.governor.validateSessionId(sessionId);
241
+ if (sessionIdError) {
242
+ throw new Error(sessionIdError);
243
+ }
244
+ if (cwd) {
245
+ const cwdError = this.governor.validateCwd(cwd);
246
+ if (cwdError) {
247
+ throw new Error(cwdError);
248
+ }
249
+ }
250
+ const lock = await this.lockManager.acquire(sessionId, "createSession");
251
+ try {
252
+ if (this.sessions.has(sessionId)) {
253
+ throw new Error(`Session ${sessionId} already exists`);
254
+ }
255
+ if (!this.governor.tryReserveSessionSlot()) {
256
+ throw new Error(
257
+ `Session limit reached (${this.governor.getConfig().maxSessions} sessions)`
258
+ );
259
+ }
260
+ try {
261
+ const { session } = await this.createAgentSessionWithSanitizedNpmEnv({
262
+ cwd: cwd ?? process.cwd()
263
+ });
264
+ await session.bindExtensions({
265
+ uiContext: createServerUIContext(
266
+ sessionId,
267
+ this.extensionUI,
268
+ (sid, event) => this.broadcastEvent(sid, event)
269
+ )
270
+ });
271
+ if (this.sessions.has(sessionId)) {
272
+ session.dispose();
273
+ throw new Error(`Session ${sessionId} already exists`);
274
+ }
275
+ this.sessions.set(sessionId, session);
276
+ this.sessionCreatedAt.set(sessionId, /* @__PURE__ */ new Date());
277
+ this.versionStore.initialize(sessionId);
278
+ this.governor.recordHeartbeat(sessionId);
279
+ const unsubscribe = session.subscribe((event) => {
280
+ this.broadcastEvent(sessionId, event);
281
+ });
282
+ this.unsubscribers.set(sessionId, unsubscribe);
283
+ const sessionInfo = this.getSessionInfo(sessionId);
284
+ if (!session.sessionFile) {
285
+ throw new Error("Session created without session file - cannot persist");
286
+ }
287
+ await this.sessionStore.save({
288
+ sessionId,
289
+ sessionFile: session.sessionFile,
290
+ cwd: cwd ?? process.cwd(),
291
+ createdAt: sessionInfo.createdAt,
292
+ modelId: session.model?.id
293
+ });
294
+ return sessionInfo;
295
+ } catch (error) {
296
+ this.governor.releaseSessionSlot();
297
+ throw error;
298
+ }
299
+ } finally {
300
+ lock.release();
301
+ }
302
+ }
303
+ async deleteSession(sessionId) {
304
+ const lock = await this.lockManager.acquire(sessionId, "deleteSession");
305
+ try {
306
+ const session = this.sessions.get(sessionId);
307
+ if (!session) {
308
+ throw new Error(`Session ${sessionId} not found`);
309
+ }
310
+ this.extensionUI.cancelSessionRequests(sessionId);
311
+ this.sessions.delete(sessionId);
312
+ this.sessionCreatedAt.delete(sessionId);
313
+ this.versionStore.delete(sessionId);
314
+ this.governor.unregisterSession(sessionId);
315
+ this.governor.cleanupStaleData(new Set(this.sessions.keys()));
316
+ const unsubscribe = this.unsubscribers.get(sessionId);
317
+ if (unsubscribe) {
318
+ this.unsubscribers.delete(sessionId);
319
+ try {
320
+ unsubscribe();
321
+ } catch (error) {
322
+ console.error(`[deleteSession] Failed to unsubscribe:`, error);
323
+ }
324
+ }
325
+ try {
326
+ session.dispose();
327
+ } catch (error) {
328
+ console.error(`[deleteSession] Failed to dispose session:`, error);
329
+ }
330
+ for (const subscriber of this.subscribers) {
331
+ subscriber.subscribedSessions.delete(sessionId);
332
+ }
333
+ await this.sessionStore.delete(sessionId);
334
+ } finally {
335
+ lock.release();
336
+ }
337
+ }
338
+ getSession(sessionId) {
339
+ return this.sessions.get(sessionId);
340
+ }
341
+ getSessionInfo(sessionId) {
342
+ const session = this.sessions.get(sessionId);
343
+ if (!session) return void 0;
344
+ const createdAt = this.sessionCreatedAt.get(sessionId);
345
+ if (!createdAt) {
346
+ console.error(`[getSessionInfo] Missing createdAt for session ${sessionId}`);
347
+ return void 0;
348
+ }
349
+ return {
350
+ sessionId,
351
+ sessionName: session.sessionName,
352
+ sessionFile: session.sessionFile,
353
+ model: session.model,
354
+ thinkingLevel: session.thinkingLevel,
355
+ isStreaming: session.isStreaming,
356
+ messageCount: session.messages.length,
357
+ createdAt: createdAt.toISOString()
358
+ };
359
+ }
360
+ listSessions() {
361
+ const infos = [];
362
+ for (const sessionId of this.sessions.keys()) {
363
+ const info = this.getSessionInfo(sessionId);
364
+ if (info) infos.push(info);
365
+ }
366
+ return infos;
367
+ }
368
+ // ==========================================================================
369
+ // SESSION PERSISTENCE (ADR-0007)
370
+ // ==========================================================================
371
+ /**
372
+ * List stored sessions that can be loaded.
373
+ * These are sessions that existed in previous server runs OR discovered on disk.
374
+ */
375
+ async listStoredSessions() {
376
+ return this.sessionStore.listAllSessions();
377
+ }
378
+ /**
379
+ * Load a session from a stored session file.
380
+ * Creates a new in-memory session that reads from the existing session file.
381
+ */
382
+ async loadSession(sessionId, sessionPath) {
383
+ const sessionIdError = this.governor.validateSessionId(sessionId);
384
+ if (sessionIdError) {
385
+ throw new Error(sessionIdError);
386
+ }
387
+ const lock = await this.lockManager.acquire(sessionId, "loadSession");
388
+ try {
389
+ if (this.sessions.has(sessionId)) {
390
+ throw new Error(`Session ${sessionId} already exists`);
391
+ }
392
+ if (!this.governor.tryReserveSessionSlot()) {
393
+ throw new Error(
394
+ `Session limit reached (${this.governor.getConfig().maxSessions} sessions)`
395
+ );
396
+ }
397
+ try {
398
+ const { session } = await this.createAgentSessionWithSanitizedNpmEnv({
399
+ cwd: process.cwd()
400
+ });
401
+ const switched = await session.switchSession(sessionPath);
402
+ if (!switched) {
403
+ session.dispose();
404
+ throw new Error(`Failed to load session from ${sessionPath}`);
405
+ }
406
+ await session.bindExtensions({
407
+ uiContext: createServerUIContext(
408
+ sessionId,
409
+ this.extensionUI,
410
+ (sid, event) => this.broadcastEvent(sid, event)
411
+ )
412
+ });
413
+ if (this.sessions.has(sessionId)) {
414
+ session.dispose();
415
+ throw new Error(`Session ${sessionId} already exists`);
416
+ }
417
+ this.sessions.set(sessionId, session);
418
+ this.sessionCreatedAt.set(sessionId, /* @__PURE__ */ new Date());
419
+ this.versionStore.initialize(sessionId);
420
+ this.governor.recordHeartbeat(sessionId);
421
+ const unsubscribe = session.subscribe((event) => {
422
+ this.broadcastEvent(sessionId, event);
423
+ });
424
+ this.unsubscribers.set(sessionId, unsubscribe);
425
+ const sessionInfo = this.getSessionInfo(sessionId);
426
+ if (!session.sessionFile) {
427
+ throw new Error("Session loaded without session file - cannot persist metadata");
428
+ }
429
+ await this.sessionStore.save({
430
+ sessionId,
431
+ sessionFile: session.sessionFile,
432
+ cwd: process.cwd(),
433
+ createdAt: sessionInfo.createdAt,
434
+ modelId: session.model?.id
435
+ });
436
+ return sessionInfo;
437
+ } catch (error) {
438
+ this.governor.releaseSessionSlot();
439
+ throw error;
440
+ }
441
+ } finally {
442
+ lock.release();
443
+ }
444
+ }
445
+ /**
446
+ * Get the session store for direct access (e.g., cleanup).
447
+ */
448
+ getSessionStore() {
449
+ return this.sessionStore;
450
+ }
451
+ /**
452
+ * Start periodic cleanup of orphaned session metadata and expired sessions.
453
+ * @param intervalMs Cleanup interval in milliseconds (default: 1 hour)
454
+ */
455
+ startSessionCleanup(intervalMs) {
456
+ this.sessionStore.startPeriodicCleanup(intervalMs);
457
+ this.startSessionExpirationCheck(intervalMs);
458
+ }
459
+ /**
460
+ * Stop periodic cleanup.
461
+ */
462
+ stopSessionCleanup() {
463
+ this.sessionStore.stopPeriodicCleanup();
464
+ this.stopSessionExpirationCheck();
465
+ }
466
+ /**
467
+ * Run a one-time cleanup of orphaned session metadata and expired sessions.
468
+ */
469
+ async cleanupSessions() {
470
+ await this.cleanupExpiredSessions();
471
+ return this.sessionStore.cleanup();
472
+ }
473
+ /**
474
+ * Start periodic check for expired sessions (maxSessionLifetimeMs).
475
+ */
476
+ startSessionExpirationCheck(intervalMs = 36e5) {
477
+ if (this.sessionExpirationTimer) {
478
+ return;
479
+ }
480
+ this.sessionExpirationTimer = setInterval(() => {
481
+ this.cleanupExpiredSessions().catch((error) => {
482
+ console.error("[SessionManager] Session expiration cleanup failed:", error);
483
+ });
484
+ }, intervalMs);
485
+ if (this.sessionExpirationTimer.unref) {
486
+ this.sessionExpirationTimer.unref();
487
+ }
488
+ }
489
+ /**
490
+ * Stop periodic session expiration check.
491
+ */
492
+ stopSessionExpirationCheck() {
493
+ if (this.sessionExpirationTimer) {
494
+ clearInterval(this.sessionExpirationTimer);
495
+ this.sessionExpirationTimer = null;
496
+ }
497
+ }
498
+ /**
499
+ * Clean up sessions that have exceeded maxSessionLifetimeMs.
500
+ * Also cleans up stale circuit breakers for unused providers.
501
+ */
502
+ async cleanupExpiredSessions() {
503
+ const expiredIds = this.governor.getExpiredSessions();
504
+ for (const sessionId of expiredIds) {
505
+ try {
506
+ console.error(`[SessionManager] Deleting expired session: ${sessionId}`);
507
+ await this.deleteSession(sessionId);
508
+ } catch (error) {
509
+ console.error(`[SessionManager] Failed to delete expired session ${sessionId}:`, error);
510
+ }
511
+ }
512
+ const staleBreakersRemoved = this.circuitBreakers.cleanupStaleBreakers();
513
+ if (staleBreakersRemoved > 0) {
514
+ console.error(`[SessionManager] Cleaned up ${staleBreakersRemoved} stale circuit breakers`);
515
+ }
516
+ const staleBashBreakersRemoved = this.bashCircuitBreaker.cleanupStale();
517
+ if (staleBashBreakersRemoved > 0) {
518
+ console.error(
519
+ `[SessionManager] Cleaned up ${staleBashBreakersRemoved} stale bash circuit breakers`
520
+ );
521
+ }
522
+ }
523
+ // ==========================================================================
524
+ // SUBSCRIBER MANAGEMENT
525
+ // ==========================================================================
526
+ addSubscriber(subscriber) {
527
+ this.subscribers.add(subscriber);
528
+ }
529
+ removeSubscriber(subscriber) {
530
+ this.subscribers.delete(subscriber);
531
+ }
532
+ subscribeToSession(subscriber, sessionId) {
533
+ if (!this.sessions.has(sessionId)) {
534
+ throw new Error(`Session ${sessionId} not found`);
535
+ }
536
+ subscriber.subscribedSessions.add(sessionId);
537
+ }
538
+ unsubscribeFromSession(subscriber, sessionId) {
539
+ subscriber.subscribedSessions.delete(sessionId);
540
+ }
541
+ // ==========================================================================
542
+ // EVENT BROADCAST
543
+ // ==========================================================================
544
+ broadcastEvent(sessionId, event) {
545
+ const rpcEvent = {
546
+ type: "event",
547
+ sessionId,
548
+ event
549
+ };
550
+ let data;
551
+ try {
552
+ data = JSON.stringify(rpcEvent);
553
+ } catch (error) {
554
+ console.error(`[broadcastEvent] JSON serialization failed:`, error);
555
+ return;
556
+ }
557
+ const snapshot = [...this.subscribers];
558
+ for (const subscriber of snapshot) {
559
+ if (subscriber.subscribedSessions.has(sessionId)) {
560
+ try {
561
+ subscriber.send(data);
562
+ } catch (error) {
563
+ console.error(`[broadcastEvent] Failed to send to subscriber:`, error);
564
+ }
565
+ }
566
+ }
567
+ }
568
+ broadcast(data) {
569
+ const snapshot = [...this.subscribers];
570
+ for (const subscriber of snapshot) {
571
+ try {
572
+ subscriber.send(data);
573
+ } catch (error) {
574
+ console.error(`[broadcast] Failed to send to subscriber:`, error);
575
+ }
576
+ }
577
+ }
578
+ // ==========================================================================
579
+ // COMMAND EXECUTION CONTEXT
580
+ // ==========================================================================
581
+ /**
582
+ * Create the command execution context for server command handlers.
583
+ * This is the NEXUS seam - provides everything handlers need without
584
+ * direct coupling to SessionManager internals.
585
+ */
586
+ createCommandContext() {
587
+ return {
588
+ getSession: (sessionId) => this.sessions.get(sessionId),
589
+ getSessionInfo: (sessionId) => this.getSessionInfo(sessionId),
590
+ listSessions: () => this.listSessions(),
591
+ createSession: (sessionId, cwd) => this.createSession(sessionId, cwd),
592
+ deleteSession: (sessionId) => this.deleteSession(sessionId),
593
+ loadSession: (sessionId, sessionPath) => this.loadSession(sessionId, sessionPath),
594
+ listStoredSessions: () => this.listStoredSessions(),
595
+ getMetrics: () => this.buildMetricsResponse(),
596
+ getMemoryMetrics: () => this.memoryMetricsProvider?.(),
597
+ getHealth: () => this.buildHealthResponse(),
598
+ handleUIResponse: (command) => this.extensionUI.handleUIResponse({
599
+ id: command.id,
600
+ sessionId: command.sessionId,
601
+ type: "extension_ui_response",
602
+ requestId: command.requestId,
603
+ response: command.response
604
+ }),
605
+ routeSessionCommand: (session, command, getSessionInfo) => routeSessionCommand(session, command, getSessionInfo),
606
+ generateSessionId: () => this.generateSessionId(),
607
+ recordHeartbeat: (sessionId) => this.governor.recordHeartbeat(sessionId),
608
+ getCircuitBreakers: () => ({
609
+ hasOpenCircuit: () => this.circuitBreakers.hasOpenCircuit(),
610
+ getBreaker: (provider) => {
611
+ const breaker = this.circuitBreakers.getBreaker(provider);
612
+ return {
613
+ canExecute: () => breaker.canExecute(),
614
+ recordSuccess: (elapsedMs) => breaker.recordSuccess(elapsedMs),
615
+ recordFailure: (type) => breaker.recordFailure(type)
616
+ };
617
+ }
618
+ }),
619
+ getBashCircuitBreaker: () => ({
620
+ canExecute: (sessionId) => this.bashCircuitBreaker.canExecute(sessionId),
621
+ recordSuccess: (sessionId) => this.bashCircuitBreaker.recordSuccess(sessionId),
622
+ recordTimeout: (sessionId) => this.bashCircuitBreaker.recordTimeout(sessionId),
623
+ recordSpawnError: (sessionId) => this.bashCircuitBreaker.recordSpawnError(sessionId),
624
+ hasOpenCircuit: () => this.bashCircuitBreaker.hasOpenCircuit(),
625
+ getMetrics: () => this.bashCircuitBreaker.getMetrics()
626
+ }),
627
+ getDefaultCommandTimeoutMs: () => this.defaultCommandTimeoutMs
628
+ };
629
+ }
630
+ /**
631
+ * Build the metrics response (extracted for handler use).
632
+ */
633
+ buildMetricsResponse() {
634
+ const governorMetrics = this.governor.getMetrics();
635
+ const replayStats = this.replayStore.getStats();
636
+ const versionStats = this.versionStore.getStats();
637
+ const executionStats = this.executionEngine.getStats();
638
+ const lockStats = this.lockManager.getStats();
639
+ const extensionUIStats = this.extensionUI.getStats();
640
+ const circuitBreakerMetrics = this.circuitBreakers.getAllMetrics();
641
+ const bashCircuitBreakerMetrics = this.bashCircuitBreaker.getMetrics();
642
+ const sessionStoreStats = {
643
+ metadataResetCount: this.sessionStore.getMetadataResetCount()
644
+ };
645
+ return {
646
+ type: "response",
647
+ command: "get_metrics",
648
+ success: true,
649
+ data: {
650
+ ...governorMetrics,
651
+ stores: {
652
+ replay: replayStats,
653
+ version: versionStats,
654
+ execution: executionStats,
655
+ lock: lockStats,
656
+ extensionUI: extensionUIStats,
657
+ sessionStore: sessionStoreStats
658
+ },
659
+ circuitBreakers: circuitBreakerMetrics,
660
+ bashCircuitBreaker: bashCircuitBreakerMetrics
661
+ }
662
+ };
663
+ }
664
+ /**
665
+ * Build the health check response (extracted for handler use).
666
+ */
667
+ buildHealthResponse() {
668
+ const health = this.governor.isHealthy();
669
+ const hasOpenCircuit = this.circuitBreakers.hasOpenCircuit();
670
+ const hasOpenBashCircuit = this.bashCircuitBreaker.hasOpenCircuit();
671
+ const issues = [...health.issues];
672
+ if (hasOpenCircuit) {
673
+ issues.push("One or more LLM provider circuits are open");
674
+ }
675
+ if (hasOpenBashCircuit) {
676
+ issues.push("Bash command circuit breaker is open");
677
+ }
678
+ return {
679
+ type: "response",
680
+ command: "health_check",
681
+ success: true,
682
+ data: {
683
+ ...health,
684
+ hasOpenCircuit,
685
+ hasOpenBashCircuit,
686
+ issues
687
+ }
688
+ };
689
+ }
690
+ // ==========================================================================
691
+ // COMMAND EXECUTION
692
+ // ==========================================================================
693
+ async executeCommand(command) {
694
+ const id = getCommandId(command);
695
+ const commandType = getCommandType(command);
696
+ const sessionId = getSessionId(command);
697
+ const commandId = this.replayStore.getOrCreateCommandId(command);
698
+ const dependsOn = getCommandDependsOn(command) ?? [];
699
+ const ifSessionVersion = getCommandIfSessionVersion(command);
700
+ const idempotencyKey = getCommandIdempotencyKey(command);
701
+ const laneKey = this.executionEngine.getLaneKey(command);
702
+ const fingerprint = this.replayStore.getCommandFingerprint(command);
703
+ if (this.isShuttingDown) {
704
+ return {
705
+ id,
706
+ type: "response",
707
+ command: commandType ?? "unknown",
708
+ success: false,
709
+ error: "Server is shutting down"
710
+ };
711
+ }
712
+ const validationErrors = validateCommand(command);
713
+ if (validationErrors.length > 0) {
714
+ return {
715
+ id,
716
+ type: "response",
717
+ command: commandType ?? "unknown",
718
+ success: false,
719
+ error: `Validation failed: ${formatValidationErrors(validationErrors)}`
720
+ };
721
+ }
722
+ this.replayStore.cleanupIdempotencyCache();
723
+ this.broadcastCommandLifecycle("command_accepted", {
724
+ commandId,
725
+ commandType,
726
+ sessionId,
727
+ dependsOn,
728
+ ifSessionVersion,
729
+ idempotencyKey
730
+ });
731
+ const finalizeResponse = (response2) => {
732
+ this.broadcastCommandLifecycle("command_finished", {
733
+ commandId,
734
+ commandType,
735
+ sessionId,
736
+ dependsOn,
737
+ ifSessionVersion,
738
+ idempotencyKey,
739
+ success: response2.success,
740
+ error: response2.success ? void 0 : response2.error,
741
+ sessionVersion: response2.sessionVersion,
742
+ replayed: response2.replayed
743
+ });
744
+ return response2;
745
+ };
746
+ const replayCheck = this.replayStore.checkReplay(command, fingerprint);
747
+ if (replayCheck.kind === "conflict") {
748
+ return finalizeResponse(replayCheck.response);
749
+ }
750
+ if (replayCheck.kind === "replay_cached") {
751
+ return finalizeResponse(replayCheck.response);
752
+ }
753
+ if (replayCheck.kind === "replay_inflight") {
754
+ const replayed = await replayCheck.promise;
755
+ return finalizeResponse(replayed);
756
+ }
757
+ const rateLimitKey = sessionId ?? "_server_";
758
+ const rateLimitResult = this.governor.canExecuteCommand(rateLimitKey);
759
+ if (!rateLimitResult.allowed) {
760
+ return finalizeResponse({
761
+ id,
762
+ type: "response",
763
+ command: commandType,
764
+ success: false,
765
+ error: rateLimitResult.reason
766
+ });
767
+ }
768
+ if (commandType === "extension_ui_response" && sessionId) {
769
+ const extRateLimitResult = this.governor.canExecuteExtensionUIResponse(sessionId);
770
+ if (!extRateLimitResult.allowed) {
771
+ return finalizeResponse({
772
+ id,
773
+ type: "response",
774
+ command: commandType,
775
+ success: false,
776
+ error: extRateLimitResult.reason
777
+ });
778
+ }
779
+ }
780
+ const isExplicitId = typeof id === "string" && !id.startsWith(SYNTHETIC_ID_PREFIX);
781
+ if (isExplicitId && !this.replayStore.canRegisterInFlight(id)) {
782
+ return finalizeResponse({
783
+ id,
784
+ type: "response",
785
+ command: commandType,
786
+ success: false,
787
+ error: "Server busy - too many concurrent commands. Please retry."
788
+ });
789
+ }
790
+ const commandExecution = this.executionEngine.runOnLane(
791
+ laneKey,
792
+ async () => {
793
+ this.broadcastCommandLifecycle("command_started", {
794
+ commandId,
795
+ commandType,
796
+ sessionId,
797
+ dependsOn,
798
+ ifSessionVersion,
799
+ idempotencyKey
800
+ });
801
+ if (dependsOn.includes(commandId)) {
802
+ return {
803
+ id,
804
+ type: "response",
805
+ command: commandType,
806
+ success: false,
807
+ error: `Command '${commandId}' cannot depend on itself`
808
+ };
809
+ }
810
+ if (dependsOn.length > 0) {
811
+ const dependencyResult = await this.executionEngine.awaitDependencies(dependsOn, laneKey);
812
+ if (!dependencyResult.ok) {
813
+ return {
814
+ id,
815
+ type: "response",
816
+ command: commandType,
817
+ success: false,
818
+ error: dependencyResult.error
819
+ };
820
+ }
821
+ }
822
+ if (sessionId !== void 0 && ifSessionVersion !== void 0) {
823
+ const versionError = this.executionEngine.checkSessionVersion(
824
+ sessionId,
825
+ ifSessionVersion,
826
+ commandType
827
+ );
828
+ if (versionError) {
829
+ return {
830
+ id,
831
+ type: "response",
832
+ command: commandType,
833
+ success: false,
834
+ error: versionError.error
835
+ };
836
+ }
837
+ }
838
+ const rawResponse = await this.executeCommandInternal(command, id, commandType);
839
+ return this.versionStore.applyVersion(command, rawResponse);
840
+ }
841
+ );
842
+ let inFlightRecord;
843
+ if (id) {
844
+ inFlightRecord = {
845
+ commandType,
846
+ laneKey,
847
+ fingerprint,
848
+ promise: commandExecution
849
+ };
850
+ const registered = this.replayStore.registerInFlight(id, inFlightRecord);
851
+ if (!registered) {
852
+ return finalizeResponse({
853
+ id,
854
+ type: "response",
855
+ command: commandType,
856
+ success: false,
857
+ error: "Server busy - too many concurrent commands. Please retry."
858
+ });
859
+ }
860
+ }
861
+ this.registerInFlightCommand(commandExecution);
862
+ let response;
863
+ try {
864
+ response = await this.executionEngine.executeWithTimeout(
865
+ commandType,
866
+ commandExecution,
867
+ command
868
+ );
869
+ } catch (error) {
870
+ response = {
871
+ id,
872
+ type: "response",
873
+ command: commandType,
874
+ success: false,
875
+ error: error instanceof Error ? error.message : String(error),
876
+ timedOut: true
877
+ // Mark as timeout for debugging
878
+ };
879
+ }
880
+ if (isExplicitId) {
881
+ try {
882
+ this.replayStore.storeCommandOutcome({
883
+ commandId: id,
884
+ commandType,
885
+ laneKey,
886
+ fingerprint,
887
+ success: response.success,
888
+ error: response.success ? void 0 : response.error,
889
+ response,
890
+ sessionVersion: response.sessionVersion,
891
+ finishedAt: Date.now()
892
+ });
893
+ } catch (outcomeError) {
894
+ console.error(`[executeCommand] Failed to store command outcome for ${id}:`, outcomeError);
895
+ }
896
+ if (this.replayStore.getInFlight(id) === inFlightRecord) {
897
+ this.replayStore.unregisterInFlight(id, inFlightRecord);
898
+ }
899
+ } else if (id && this.replayStore.getInFlight(id) === inFlightRecord) {
900
+ this.replayStore.unregisterInFlight(id, inFlightRecord);
901
+ }
902
+ if (idempotencyKey) {
903
+ this.replayStore.cacheIdempotencyResult({
904
+ command,
905
+ idempotencyKey,
906
+ commandType,
907
+ fingerprint,
908
+ response
909
+ });
910
+ }
911
+ return finalizeResponse(response);
912
+ }
913
+ /**
914
+ * Internal command execution (called after tracking and rate limiting).
915
+ * Routes to server command handlers or session command handlers.
916
+ */
917
+ async executeCommandInternal(command, id, commandType) {
918
+ const failResponse = (error, responseCommand = commandType) => {
919
+ return {
920
+ id,
921
+ type: "response",
922
+ command: responseCommand,
923
+ success: false,
924
+ error
925
+ };
926
+ };
927
+ try {
928
+ const context = this.createCommandContext();
929
+ const serverResponse = routeServerCommand(command, context);
930
+ if (serverResponse !== void 0) {
931
+ const resolved = await Promise.resolve(serverResponse);
932
+ return { ...resolved, id };
933
+ }
934
+ const cmdSessionId = getSessionId(command);
935
+ const session = this.sessions.get(cmdSessionId);
936
+ if (!session) {
937
+ return failResponse(`Session ${cmdSessionId} not found`);
938
+ }
939
+ this.governor.recordHeartbeat(cmdSessionId);
940
+ const llmResponse = await executeLLMCommand(command, session, context);
941
+ if (llmResponse !== void 0) {
942
+ if (!llmResponse.success) {
943
+ return { ...llmResponse, id };
944
+ }
945
+ return llmResponse;
946
+ }
947
+ const bashResponse = await executeBashCommand(command, session, context);
948
+ if (bashResponse !== void 0) {
949
+ if (!bashResponse.success) {
950
+ return { ...bashResponse, id };
951
+ }
952
+ return bashResponse;
953
+ }
954
+ const routed = routeSessionCommand(session, command, (sid) => this.getSessionInfo(sid));
955
+ if (routed === void 0) {
956
+ return failResponse(`Unknown command type: ${commandType}`);
957
+ }
958
+ const response = await Promise.resolve(routed);
959
+ if (!response.success) {
960
+ return failResponse(response.error ?? "Unknown error", response.command);
961
+ }
962
+ return response;
963
+ } catch (error) {
964
+ return failResponse(error instanceof Error ? error.message : String(error));
965
+ }
966
+ }
967
+ // ==========================================================================
968
+ // UTILITIES
969
+ // ==========================================================================
970
+ /**
971
+ * Create an AgentSession while sanitizing npm prefix env leakage from npm scripts.
972
+ *
973
+ * npm sets npm_config_prefix for child processes. If inherited here,
974
+ * pi-coding-agent's global package installation can be redirected into the
975
+ * current project (e.g. ./lib/node_modules), causing flaky session creation.
976
+ */
977
+ async createAgentSessionWithSanitizedNpmEnv(options) {
978
+ const snapshots = SANITIZED_NPM_ENV_KEYS.map((key) => ({
979
+ key,
980
+ had: Object.hasOwn(process.env, key),
981
+ value: process.env[key]
982
+ }));
983
+ for (const snapshot of snapshots) {
984
+ if (snapshot.had) {
985
+ delete process.env[snapshot.key];
986
+ }
987
+ }
988
+ try {
989
+ return await createAgentSession(options);
990
+ } finally {
991
+ for (const snapshot of snapshots) {
992
+ if (!snapshot.had) continue;
993
+ if (snapshot.value === void 0) {
994
+ delete process.env[snapshot.key];
995
+ } else {
996
+ process.env[snapshot.key] = snapshot.value;
997
+ }
998
+ }
999
+ }
1000
+ }
1001
+ generateSessionId() {
1002
+ const timestamp = Date.now().toString(36);
1003
+ const random = crypto.randomUUID().split("-")[0];
1004
+ return `session-${timestamp}-${random}`;
1005
+ }
1006
+ }
1007
+ export {
1008
+ PiSessionManager
1009
+ };
1010
+ //# sourceMappingURL=session-manager.js.map