prjct-cli 1.6.8 → 1.6.10

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 (38) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +46 -0
  3. package/core/ai-tools/registry.ts +2 -9
  4. package/core/bus/bus.ts +24 -0
  5. package/core/commands/planning.ts +3 -5
  6. package/core/infrastructure/agent-detector.ts +2 -2
  7. package/core/infrastructure/path-manager.ts +3 -17
  8. package/core/integrations/jira/client.ts +3 -77
  9. package/core/server/server.ts +2 -4
  10. package/core/server/sse.ts +115 -59
  11. package/core/services/context-generator.ts +22 -47
  12. package/core/services/diff-generator.ts +18 -43
  13. package/core/services/stack-detector.ts +4 -20
  14. package/core/services/sync-service.ts +35 -106
  15. package/core/services/sync-verifier.ts +17 -37
  16. package/core/services/watch-service.ts +20 -3
  17. package/core/types/citations.ts +22 -0
  18. package/core/types/commands.ts +10 -0
  19. package/core/types/diff.ts +41 -0
  20. package/core/types/errors.ts +111 -0
  21. package/core/types/index.ts +80 -0
  22. package/core/types/infrastructure.ts +14 -0
  23. package/core/types/jira.ts +51 -0
  24. package/core/types/logger.ts +17 -0
  25. package/core/types/output.ts +47 -0
  26. package/core/types/project-sync.ts +109 -0
  27. package/core/types/server.ts +28 -10
  28. package/core/types/services.ts +14 -0
  29. package/core/types/stack.ts +19 -0
  30. package/core/types/sync-verifier.ts +33 -0
  31. package/core/types/workflow.ts +23 -0
  32. package/core/utils/citations.ts +2 -16
  33. package/core/utils/error-messages.ts +5 -139
  34. package/core/utils/logger.ts +3 -11
  35. package/core/utils/output.ts +6 -45
  36. package/core/workflow/workflow-preferences.ts +14 -18
  37. package/dist/bin/prjct.mjs +137 -54
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.10] - 2026-02-07
4
+
5
+ ### Bug Fixes
6
+
7
+ - resolve signal handler and EventBus listener accumulation leaks (PRJ-287) (#135)
8
+
9
+
10
+ ## [1.6.12] - 2026-02-07
11
+
12
+ ### Bug Fixes
13
+ - **Fix signal handler and EventBus listener accumulation leaks (PRJ-287)**: WatchService signal handlers (`SIGINT`/`SIGTERM`) are now stored by reference and removed in `stop()`, preventing accumulation on restart cycles. `pendingChanges` Set is cleared on stop. EventBus gains `flush()` to clear history and stale once-listeners, and `removeAllListeners(event?)` for targeted cleanup.
14
+
15
+ ### Implementation Details
16
+ Stored signal handler references as class properties (`sigintHandler`, `sigtermHandler`). In `start()`, old handlers are removed before new ones are added. In `stop()`, handlers are removed via `process.off()` and `pendingChanges` is cleared. EventBus `flush()` clears history array and all once-listeners. `removeAllListeners()` supports both targeted (single event) and global cleanup.
17
+
18
+ ### Learnings
19
+ - Arrow functions passed to `process.on()` cannot be removed — must store named handler references for `process.off()`.
20
+ - Cleanup code after `process.exit(0)` is unreachable — perform all cleanup before the exit call.
21
+
22
+ ### Test Plan
23
+
24
+ #### For QA
25
+ 1. Start/stop watch mode 10 times — verify only 2 signal handlers (not 20)
26
+ 2. Trigger file changes, stop — verify `pendingChanges` cleared
27
+ 3. Emit 50 events, call `flush()` — verify history empty
28
+ 4. Register `once()` for unfired event, `flush()` — verify listener removed
29
+ 5. `removeAllListeners('event')` — verify only that event cleared
30
+ 6. `removeAllListeners()` — verify all cleared
31
+
32
+ #### For Users
33
+ **What changed:** WatchService no longer leaks signal handlers on restart. EventBus has `flush()` and `removeAllListeners()`.
34
+ **Breaking changes:** None.
35
+
36
+ ## [1.6.9] - 2026-02-07
37
+
38
+ ### Bug Fixes
39
+
40
+ - resolve SSE zombie connections and infinite promise leak (PRJ-286) (#134)
41
+
42
+ ## [1.6.11] - 2026-02-07
43
+
44
+ ### Bug Fixes
45
+ - **Fix SSE zombie connections and infinite promise leak (PRJ-286)**: Replaced infinite `await new Promise(() => {})` with AbortController-based mechanism that resolves on client removal. Added max client lifetime (1 hour) with per-client TTL timeout. Added periodic reaper (every 5 min) that scans for and removes zombie entries from the clients Map. Consolidated duplicate cleanup paths into single idempotent `removeClient(id)` function. Added `shutdown()` to SSEManager for clean server stop. All timers use `unref()` to avoid blocking process exit.
46
+
47
+ ### Implementation Details
48
+ Replaced the infinite pending promise in `streamSSE` callback with an `AbortController` whose signal resolves the await when the client is removed. Internal client state (`heartbeatInterval`, `ttlTimeout`, `abortController`) is tracked in an `InternalClient` wrapper separate from the public `SSEClient` type. The public `SSEClient` interface gained only `connectedAt` for staleness detection.
49
+
50
+ ### Learnings
51
+ - AbortController integrates cleanly with Hono's `streamSSE` — the async callback needs to await *something*, and a signal-based promise is the right primitive.
52
+ - Timer `unref()` has different shapes between Bun (number) and Node (Timeout object) — use `typeof` check before calling.
53
+ - Idempotent cleanup functions eliminate race conditions between heartbeat failure and stream abort handlers.
54
+
55
+ ### Test Plan
56
+
57
+ #### For QA
58
+ 1. Start prjct server, connect SSE client to `/api/events` — verify `connected` event
59
+ 2. Disconnect client gracefully — verify `clients.size === 0`
60
+ 3. Kill client process (ungraceful) — verify heartbeat cleanup within 30s
61
+ 4. Connect client, wait >1 hour — verify TTL auto-disconnect
62
+ 5. Connect 5+ clients, kill all — verify reaper cleans all within 5 min
63
+ 6. Call `server.stop()` — verify all clients disconnected and reaper stopped
64
+
65
+ #### For Users
66
+ **What changed:** SSE connections now clean up reliably on disconnect, have a 1-hour max lifetime, and a background reaper removes zombie connections every 5 minutes.
67
+ **Breaking changes:** `SSEManager` interface now includes `shutdown()`. `SSEClient` now includes `connectedAt`.
68
+
69
+ ## [1.6.10] - 2026-02-07
70
+
71
+ ### Documentation
72
+ - **Document all environment variables (PRJ-90)**: Added comprehensive environment variable documentation to README.md covering all 13 env vars used by prjct-cli. Organized into Configuration, JIRA Integration, and Agent Detection categories with defaults, descriptions, and usage examples. Added inline comments at all `process.env` read sites in 6 source files.
73
+
74
+ ### Test Plan
75
+
76
+ #### For QA
77
+ 1. `bun run build` — should succeed
78
+ 2. `bun run lint` — no errors
79
+ 3. Verify README.md renders correctly on GitHub (env vars tables)
80
+
81
+ #### For Users
82
+ **What changed:** New "Environment Variables" section in README.md with full documentation of all configurable env vars.
83
+ **Breaking changes:** None.
84
+
3
85
  ## [1.6.8] - 2026-02-07
4
86
 
5
87
  ### Refactoring
package/README.md CHANGED
@@ -132,6 +132,52 @@ prjct --version # Show version + provider status
132
132
  prjct --help # Show help
133
133
  ```
134
134
 
135
+ ## Environment Variables
136
+
137
+ ### Configuration
138
+
139
+ | Variable | Default | Description |
140
+ |----------|---------|-------------|
141
+ | `PRJCT_CLI_HOME` | `~/.prjct-cli` | Override global storage location. Useful for tests or sandboxed environments. |
142
+ | `PRJCT_DEBUG` | _(unset)_ | Enable debug logging. Values: `1`, `true`, or a log level (`error`, `warn`, `info`, `debug`). |
143
+ | `DEBUG` | _(unset)_ | Fallback debug flag (used if `PRJCT_DEBUG` is not set). Values: `1`, `true`, or `prjct`. |
144
+ | `CI` | _(unset)_ | Set automatically in CI environments. Skips interactive prompts. |
145
+
146
+ ### JIRA Integration
147
+
148
+ | Variable | Default | Description |
149
+ |----------|---------|-------------|
150
+ | `JIRA_BASE_URL` | _(none)_ | JIRA instance URL (e.g., `https://myorg.atlassian.net`). |
151
+ | `JIRA_EMAIL` | _(none)_ | Email for JIRA API authentication. |
152
+ | `JIRA_API_TOKEN` | _(none)_ | API token for JIRA authentication. Generate at [Atlassian API tokens](https://id.atlassian.com/manage-profile/security/api-tokens). |
153
+
154
+ ### Agent Detection (Auto-set)
155
+
156
+ These are typically set by the AI agent runtime, not by users:
157
+
158
+ | Variable | Description |
159
+ |----------|-------------|
160
+ | `CLAUDE_AGENT` | Set when running inside Claude Code. |
161
+ | `ANTHROPIC_CLAUDE` | Alternative Claude environment indicator. |
162
+ | `MCP_AVAILABLE` | Set when MCP (Model Context Protocol) is available. |
163
+ | `HOME` / `USERPROFILE` | Standard OS home directory (used for path resolution). |
164
+
165
+ ### Usage Examples
166
+
167
+ ```bash
168
+ # Enable debug logging
169
+ PRJCT_DEBUG=1 prjct sync
170
+
171
+ # Use a custom storage location
172
+ PRJCT_CLI_HOME=/tmp/prjct-test prjct init
173
+
174
+ # Configure JIRA integration via env vars
175
+ export JIRA_BASE_URL=https://myorg.atlassian.net
176
+ export JIRA_EMAIL=you@example.com
177
+ export JIRA_API_TOKEN=your-api-token
178
+ prjct jira setup
179
+ ```
180
+
135
181
  ## Requirements
136
182
 
137
183
  - Node.js 18+ or Bun 1.0+
@@ -13,19 +13,12 @@ import { exec } from 'node:child_process'
13
13
  import os from 'node:os'
14
14
  import path from 'node:path'
15
15
  import { promisify } from 'node:util'
16
+ import type { AIToolConfig } from '../types'
16
17
  import { fileExists } from '../utils/fs-helpers'
17
18
 
18
19
  const execAsync = promisify(exec)
19
20
 
20
- export interface AIToolConfig {
21
- id: string
22
- name: string
23
- outputFile: string
24
- outputPath: 'repo' | 'global' // 'repo' = project root, 'global' = ~/.prjct-cli/projects/{id}/context/
25
- maxTokens: number
26
- format: 'detailed' | 'concise' | 'minimal' | 'json'
27
- description: string
28
- }
21
+ export type { AIToolConfig } from '../types'
29
22
 
30
23
  /**
31
24
  * Supported AI tools registry
package/core/bus/bus.ts CHANGED
@@ -237,6 +237,30 @@ class EventBus {
237
237
  this.onceListeners.clear()
238
238
  }
239
239
 
240
+ /**
241
+ * Flush event history and clean up stale once-listeners.
242
+ * Call on task completion, project switch, or periodically.
243
+ */
244
+ flush(): void {
245
+ this.history = []
246
+
247
+ // Remove once-listeners for events that were never fired
248
+ this.onceListeners.clear()
249
+ }
250
+
251
+ /**
252
+ * Remove all listeners for a specific event, or all events if none specified.
253
+ */
254
+ removeAllListeners(event?: string): void {
255
+ if (event) {
256
+ this.listeners.delete(event)
257
+ this.onceListeners.delete(event)
258
+ } else {
259
+ this.listeners.clear()
260
+ this.onceListeners.clear()
261
+ }
262
+ }
263
+
240
264
  /**
241
265
  * Get count of listeners for an event
242
266
  */
@@ -9,7 +9,7 @@ import commandInstaller from '../infrastructure/command-installer'
9
9
  import { generateUUID } from '../schemas'
10
10
  import type { Priority, TaskSection, TaskType } from '../schemas/state'
11
11
  import { ideasStorage, queueStorage } from '../storage'
12
- import type { CommandResult, ProjectContext } from '../types'
12
+ import type { CommandResult, InitOptions, ProjectContext } from '../types'
13
13
  import { getErrorMessage } from '../types/fs'
14
14
  import { showNextSteps } from '../utils/next-steps'
15
15
  import { OnboardingWizard } from '../wizard'
@@ -34,10 +34,7 @@ async function getAnalysisCommands(): Promise<import('./analysis').AnalysisComma
34
34
  return _analysisCommands
35
35
  }
36
36
 
37
- export interface InitOptions {
38
- yes?: boolean // Skip interactive wizard, use defaults
39
- idea?: string | null // Initial idea for architect mode
40
- }
37
+ export type { InitOptions } from '../types'
41
38
 
42
39
  export class PlanningCommands extends PrjctCommandsBase {
43
40
  /**
@@ -71,6 +68,7 @@ export class PlanningCommands extends PrjctCommandsBase {
71
68
 
72
69
  // Determine if we should run interactive wizard
73
70
  const isTTY = process.stdout.isTTY && process.stdin.isTTY
71
+ // CI: Skip interactive prompts in CI environments
74
72
  const skipWizard = opts.yes || !isTTY || process.env.CI === 'true'
75
73
 
76
74
  // Run wizard if interactive
@@ -80,10 +80,10 @@ const TERMINAL_AGENT: DetectedAgent = {
80
80
  // ============ Detection Functions ============
81
81
 
82
82
  export async function isClaudeEnvironment(): Promise<boolean> {
83
- // Environment variables
83
+ // CLAUDE_AGENT / ANTHROPIC_CLAUDE: Set by Claude runtime to indicate agent environment
84
84
  if (process.env.CLAUDE_AGENT || process.env.ANTHROPIC_CLAUDE) return true
85
85
 
86
- // MCP availability
86
+ // MCP_AVAILABLE: Set when Model Context Protocol is available
87
87
  if (global.mcp || process.env.MCP_AVAILABLE) return true
88
88
 
89
89
  // Configuration files
@@ -14,26 +14,11 @@ import fs from 'node:fs/promises'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { globSync } from 'glob'
17
- import type { SessionInfo } from '../types'
17
+ import type { MonorepoInfo, MonorepoPackage, SessionInfo } from '../types'
18
18
  import * as dateHelper from '../utils/date-helper'
19
19
  import * as fileHelper from '../utils/file-helper'
20
20
 
21
- /**
22
- * Monorepo detection result
23
- */
24
- export interface MonorepoInfo {
25
- isMonorepo: boolean
26
- type: 'pnpm' | 'npm' | 'yarn' | 'lerna' | 'nx' | 'rush' | 'turborepo' | null
27
- rootPath: string
28
- packages: MonorepoPackage[]
29
- }
30
-
31
- export interface MonorepoPackage {
32
- name: string
33
- path: string
34
- relativePath: string
35
- hasPrjctMd: boolean
36
- }
21
+ export type { MonorepoInfo, MonorepoPackage } from '../types'
37
22
 
38
23
  class PathManager {
39
24
  globalBaseDir: string
@@ -41,6 +26,7 @@ class PathManager {
41
26
  globalConfigDir: string
42
27
 
43
28
  constructor() {
29
+ // PRJCT_CLI_HOME: Override global storage location (default: ~/.prjct-cli)
44
30
  const envOverride = process.env.PRJCT_CLI_HOME?.trim()
45
31
  this.globalBaseDir = envOverride
46
32
  ? path.resolve(envOverride)
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { getErrorMessage } from '../../types/fs'
18
+ import type { JiraAuthMode, JiraIssue, JiraProject, JiraSearchResponse } from '../../types/jira'
18
19
  import type {
19
20
  CreateIssueInput,
20
21
  FetchOptions,
@@ -27,77 +28,6 @@ import type {
27
28
  UpdateIssueInput,
28
29
  } from '../issue-tracker/types'
29
30
 
30
- // =============================================================================
31
- // JIRA API Types
32
- // =============================================================================
33
-
34
- interface JiraIssue {
35
- id: string
36
- key: string
37
- self: string
38
- fields: {
39
- summary: string
40
- description?:
41
- | {
42
- type: string
43
- content: Array<{
44
- type: string
45
- content?: Array<{ type: string; text?: string }>
46
- }>
47
- }
48
- | string
49
- | null
50
- status: {
51
- id: string
52
- name: string
53
- statusCategory: {
54
- key: string // 'new', 'indeterminate', 'done'
55
- name: string
56
- }
57
- }
58
- priority?: {
59
- id: string
60
- name: string
61
- }
62
- issuetype: {
63
- id: string
64
- name: string
65
- subtask: boolean
66
- }
67
- assignee?: {
68
- accountId: string
69
- displayName: string
70
- emailAddress?: string
71
- }
72
- reporter?: {
73
- accountId: string
74
- displayName: string
75
- emailAddress?: string
76
- }
77
- project: {
78
- id: string
79
- key: string
80
- name: string
81
- }
82
- labels: string[]
83
- created: string
84
- updated: string
85
- }
86
- }
87
-
88
- interface JiraSearchResponse {
89
- issues: JiraIssue[]
90
- total: number
91
- maxResults: number
92
- startAt: number
93
- }
94
-
95
- interface JiraProject {
96
- id: string
97
- key: string
98
- name: string
99
- }
100
-
101
31
  // =============================================================================
102
32
  // Status/Priority Mapping
103
33
  // =============================================================================
@@ -170,11 +100,7 @@ const PRIORITY_TO_JIRA: Record<IssuePriority, string> = {
170
100
  none: 'Medium',
171
101
  }
172
102
 
173
- // =============================================================================
174
- // Auth Mode Type
175
- // =============================================================================
176
-
177
- export type JiraAuthMode = 'api-token' | 'mcp' | 'none'
103
+ export type { JiraAuthMode } from '../../types/jira'
178
104
 
179
105
  // =============================================================================
180
106
  // JIRA Provider Implementation
@@ -225,7 +151,7 @@ export class JiraProvider implements IssueTrackerProvider {
225
151
  async initialize(config: JiraConfig): Promise<void> {
226
152
  this.config = config
227
153
 
228
- // Get credentials from config or environment
154
+ // JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN: JIRA API credentials (fallback from config)
229
155
  const baseUrl = config.baseUrl || process.env.JIRA_BASE_URL
230
156
  const email = process.env.JIRA_EMAIL
231
157
  const apiToken = config.apiKey || process.env.JIRA_API_TOKEN
@@ -11,15 +11,12 @@
11
11
  import { Hono } from 'hono'
12
12
  import { cors } from 'hono/cors'
13
13
  import { logger } from 'hono/logger'
14
- import type { ServerConfig, ServerInstance } from '../types'
14
+ import type { ServerConfig, ServerHandle, ServerInstance } from '../types'
15
15
  import { isBun } from '../utils/runtime'
16
16
  import { createRoutes } from './routes'
17
17
  import { createExtendedRoutes } from './routes-extended'
18
18
  import { createSSEManager } from './sse'
19
19
 
20
- // Server handle type that works for both runtimes
21
- type ServerHandle = { stop: () => void } | null
22
-
23
20
  /**
24
21
  * Create and configure the HTTP server
25
22
  */
@@ -118,6 +115,7 @@ export function createServer(config: ServerConfig): ServerInstance {
118
115
  },
119
116
 
120
117
  stop() {
118
+ sseManager.shutdown()
121
119
  if (server) {
122
120
  server.stop()
123
121
  server = null
@@ -4,18 +4,71 @@
4
4
  * Handles real-time updates to connected clients.
5
5
  * Broadcasts state changes, task updates, and notifications.
6
6
  *
7
- * @version 1.0.0
7
+ * @version 2.0.0
8
8
  */
9
9
 
10
10
  import type { Context } from 'hono'
11
11
  import { streamSSE } from 'hono/streaming'
12
- import type { SSEClient, SSEManager } from '../types'
12
+ import type { SSEClient, SSEInternalClient, SSEManager } from '../types'
13
+
14
+ /** Maximum client connection lifetime in ms (1 hour) */
15
+ const MAX_CLIENT_TTL_MS = 60 * 60 * 1000
16
+
17
+ /** Reaper interval in ms (5 minutes) */
18
+ const REAPER_INTERVAL_MS = 5 * 60 * 1000
19
+
20
+ /** Heartbeat interval in ms (30 seconds) */
21
+ const HEARTBEAT_INTERVAL_MS = 30_000
13
22
 
14
23
  /**
15
24
  * Create an SSE manager for handling real-time connections
16
25
  */
17
26
  export function createSSEManager(): SSEManager {
18
- const clients = new Map<string, SSEClient>()
27
+ const clients = new Map<string, SSEInternalClient>()
28
+ let reaperInterval: ReturnType<typeof setInterval> | null = null
29
+
30
+ /**
31
+ * Single cleanup function — all disconnect paths go through here.
32
+ * Safe to call multiple times for the same clientId.
33
+ */
34
+ function removeClient(clientId: string): void {
35
+ const entry = clients.get(clientId)
36
+ if (!entry) return
37
+
38
+ clearInterval(entry.heartbeatInterval)
39
+ clearTimeout(entry.ttlTimeout)
40
+ entry.abortController.abort()
41
+ clients.delete(clientId)
42
+ }
43
+
44
+ /** Periodic reaper that removes zombie clients */
45
+ function startReaper(): void {
46
+ if (reaperInterval) return
47
+
48
+ reaperInterval = setInterval(() => {
49
+ const now = Date.now()
50
+ for (const [id, entry] of clients) {
51
+ const connectedMs = now - new Date(entry.client.connectedAt).getTime()
52
+ if (connectedMs > MAX_CLIENT_TTL_MS) {
53
+ removeClient(id)
54
+ }
55
+ }
56
+ }, REAPER_INTERVAL_MS)
57
+
58
+ // Don't block process exit
59
+ if (reaperInterval && typeof reaperInterval === 'object' && 'unref' in reaperInterval) {
60
+ reaperInterval.unref()
61
+ }
62
+ }
63
+
64
+ function stopReaper(): void {
65
+ if (reaperInterval) {
66
+ clearInterval(reaperInterval)
67
+ reaperInterval = null
68
+ }
69
+ }
70
+
71
+ startReaper()
19
72
 
20
73
  return {
21
74
  /**
@@ -24,10 +77,13 @@ export function createSSEManager(): SSEManager {
24
77
  handleConnection(c: Context) {
25
78
  return streamSSE(c, async (stream) => {
26
79
  const clientId = crypto.randomUUID()
80
+ const connectedAt = new Date().toISOString()
81
+ const abortController = new AbortController()
27
82
 
28
83
  // Register client
29
84
  const client: SSEClient = {
30
85
  id: clientId,
86
+ connectedAt,
31
87
  send: (event, data) => {
32
88
  stream.writeSSE({
33
89
  event,
@@ -35,44 +91,61 @@ export function createSSEManager(): SSEManager {
35
91
  })
36
92
  },
37
93
  close: () => {
38
- clients.delete(clientId)
94
+ removeClient(clientId)
39
95
  },
40
96
  }
41
97
 
42
- clients.set(clientId, client)
98
+ // Heartbeat — detects dead connections
99
+ const heartbeatInterval = setInterval(async () => {
100
+ try {
101
+ await stream.writeSSE({
102
+ event: 'heartbeat',
103
+ data: JSON.stringify({ timestamp: new Date().toISOString() }),
104
+ })
105
+ } catch {
106
+ removeClient(clientId)
107
+ }
108
+ }, HEARTBEAT_INTERVAL_MS)
109
+
110
+ // TTL — force-disconnect after max lifetime
111
+ const ttlTimeout = setTimeout(() => {
112
+ removeClient(clientId)
113
+ }, MAX_CLIENT_TTL_MS)
114
+
115
+ // Don't block process exit
116
+ if (typeof heartbeatInterval === 'object' && 'unref' in heartbeatInterval) {
117
+ heartbeatInterval.unref()
118
+ }
119
+ if (typeof ttlTimeout === 'object' && 'unref' in ttlTimeout) {
120
+ ttlTimeout.unref()
121
+ }
122
+
123
+ clients.set(clientId, {
124
+ client,
125
+ heartbeatInterval,
126
+ ttlTimeout,
127
+ abortController,
128
+ } as SSEInternalClient)
43
129
 
44
130
  // Send initial connection event
45
131
  await stream.writeSSE({
46
132
  event: 'connected',
47
133
  data: JSON.stringify({
48
134
  clientId,
49
- timestamp: new Date().toISOString(),
135
+ timestamp: connectedAt,
50
136
  message: 'Connected to prjct-cli server',
51
137
  }),
52
138
  })
53
139
 
54
- // Keep connection alive with heartbeat
55
- const heartbeat = setInterval(async () => {
56
- try {
57
- await stream.writeSSE({
58
- event: 'heartbeat',
59
- data: JSON.stringify({ timestamp: new Date().toISOString() }),
60
- })
61
- } catch (_error) {
62
- // Client disconnected - expected
63
- clearInterval(heartbeat)
64
- clients.delete(clientId)
65
- }
66
- }, 30000) // Every 30 seconds
67
-
68
- // Handle disconnect
140
+ // Handle stream abort (graceful disconnect)
69
141
  stream.onAbort(() => {
70
- clearInterval(heartbeat)
71
- clients.delete(clientId)
142
+ removeClient(clientId)
72
143
  })
73
144
 
74
- // Keep stream open indefinitely
75
- await new Promise(() => {})
145
+ // Wait until abort signal fires instead of infinite promise
146
+ await new Promise<void>((resolve) => {
147
+ abortController.signal.addEventListener('abort', () => resolve(), { once: true })
148
+ })
76
149
  })
77
150
  },
78
151
 
@@ -86,12 +159,11 @@ export function createSSEManager(): SSEManager {
86
159
  timestamp: new Date().toISOString(),
87
160
  }
88
161
 
89
- for (const client of clients.values()) {
162
+ for (const [id, entry] of clients) {
90
163
  try {
91
- client.send(event, message)
92
- } catch (_error) {
93
- // Client disconnected - expected
94
- clients.delete(client.id)
164
+ entry.client.send(event, message)
165
+ } catch {
166
+ removeClient(id)
95
167
  }
96
168
  }
97
169
  },
@@ -102,35 +174,19 @@ export function createSSEManager(): SSEManager {
102
174
  getClientCount() {
103
175
  return clients.size
104
176
  },
177
+
178
+ /**
179
+ * Shut down all clients and stop the reaper.
180
+ * Called on server stop.
181
+ */
182
+ shutdown() {
183
+ stopReaper()
184
+ for (const id of [...clients.keys()]) {
185
+ removeClient(id)
186
+ }
187
+ },
105
188
  }
106
189
  }
107
190
 
108
- /**
109
- * Event types for SSE broadcasts
110
- */
111
- export const SSE_EVENTS = {
112
- // Task events
113
- TASK_STARTED: 'task:started',
114
- TASK_COMPLETED: 'task:completed',
115
- TASK_PAUSED: 'task:paused',
116
- TASK_RESUMED: 'task:resumed',
117
-
118
- // Feature events
119
- FEATURE_CREATED: 'feature:created',
120
- FEATURE_SHIPPED: 'feature:shipped',
121
-
122
- // Idea events
123
- IDEA_CAPTURED: 'idea:captured',
124
- IDEA_CONVERTED: 'idea:converted',
125
-
126
- // State events
127
- STATE_UPDATED: 'state:updated',
128
- QUEUE_UPDATED: 'queue:updated',
129
-
130
- // System events
131
- CONNECTED: 'connected',
132
- HEARTBEAT: 'heartbeat',
133
- ERROR: 'error',
134
- } as const
135
-
136
- export type SSEEventType = (typeof SSE_EVENTS)[keyof typeof SSE_EVENTS]
191
+ export type { SSEEventType } from '../types/server'
192
+ export { SSE_EVENTS } from '../types/server'