prjct-cli 1.6.8 → 1.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +46 -0
- package/core/ai-tools/registry.ts +2 -9
- package/core/commands/planning.ts +3 -5
- package/core/infrastructure/agent-detector.ts +2 -2
- package/core/infrastructure/path-manager.ts +3 -17
- package/core/integrations/jira/client.ts +3 -77
- package/core/server/server.ts +2 -4
- package/core/server/sse.ts +115 -59
- package/core/services/context-generator.ts +22 -47
- package/core/services/diff-generator.ts +18 -43
- package/core/services/stack-detector.ts +4 -20
- package/core/services/sync-service.ts +35 -106
- package/core/services/sync-verifier.ts +17 -37
- package/core/types/citations.ts +22 -0
- package/core/types/commands.ts +10 -0
- package/core/types/diff.ts +41 -0
- package/core/types/errors.ts +111 -0
- package/core/types/index.ts +80 -0
- package/core/types/infrastructure.ts +14 -0
- package/core/types/jira.ts +51 -0
- package/core/types/logger.ts +17 -0
- package/core/types/output.ts +47 -0
- package/core/types/project-sync.ts +109 -0
- package/core/types/server.ts +28 -10
- package/core/types/services.ts +14 -0
- package/core/types/stack.ts +19 -0
- package/core/types/sync-verifier.ts +33 -0
- package/core/types/workflow.ts +23 -0
- package/core/utils/citations.ts +2 -16
- package/core/utils/error-messages.ts +5 -139
- package/core/utils/logger.ts +3 -11
- package/core/utils/output.ts +6 -45
- package/core/workflow/workflow-preferences.ts +14 -18
- package/dist/bin/prjct.mjs +120 -52
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.9] - 2026-02-07
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- resolve SSE zombie connections and infinite promise leak (PRJ-286) (#134)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.6.11] - 2026-02-07
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
15
|
+
### Implementation Details
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
### Learnings
|
|
19
|
+
- AbortController integrates cleanly with Hono's `streamSSE` — the async callback needs to await *something*, and a signal-based promise is the right primitive.
|
|
20
|
+
- Timer `unref()` has different shapes between Bun (number) and Node (Timeout object) — use `typeof` check before calling.
|
|
21
|
+
- Idempotent cleanup functions eliminate race conditions between heartbeat failure and stream abort handlers.
|
|
22
|
+
|
|
23
|
+
### Test Plan
|
|
24
|
+
|
|
25
|
+
#### For QA
|
|
26
|
+
1. Start prjct server, connect SSE client to `/api/events` — verify `connected` event
|
|
27
|
+
2. Disconnect client gracefully — verify `clients.size === 0`
|
|
28
|
+
3. Kill client process (ungraceful) — verify heartbeat cleanup within 30s
|
|
29
|
+
4. Connect client, wait >1 hour — verify TTL auto-disconnect
|
|
30
|
+
5. Connect 5+ clients, kill all — verify reaper cleans all within 5 min
|
|
31
|
+
6. Call `server.stop()` — verify all clients disconnected and reaper stopped
|
|
32
|
+
|
|
33
|
+
#### For Users
|
|
34
|
+
**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.
|
|
35
|
+
**Breaking changes:** `SSEManager` interface now includes `shutdown()`. `SSEClient` now includes `connectedAt`.
|
|
36
|
+
|
|
37
|
+
## [1.6.10] - 2026-02-07
|
|
38
|
+
|
|
39
|
+
### Documentation
|
|
40
|
+
- **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.
|
|
41
|
+
|
|
42
|
+
### Test Plan
|
|
43
|
+
|
|
44
|
+
#### For QA
|
|
45
|
+
1. `bun run build` — should succeed
|
|
46
|
+
2. `bun run lint` — no errors
|
|
47
|
+
3. Verify README.md renders correctly on GitHub (env vars tables)
|
|
48
|
+
|
|
49
|
+
#### For Users
|
|
50
|
+
**What changed:** New "Environment Variables" section in README.md with full documentation of all configurable env vars.
|
|
51
|
+
**Breaking changes:** None.
|
|
52
|
+
|
|
3
53
|
## [1.6.8] - 2026-02-07
|
|
4
54
|
|
|
5
55
|
### 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
|
|
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
|
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
package/core/server/server.ts
CHANGED
|
@@ -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
|
package/core/server/sse.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
94
|
+
removeClient(clientId)
|
|
39
95
|
},
|
|
40
96
|
}
|
|
41
97
|
|
|
42
|
-
|
|
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:
|
|
135
|
+
timestamp: connectedAt,
|
|
50
136
|
message: 'Connected to prjct-cli server',
|
|
51
137
|
}),
|
|
52
138
|
})
|
|
53
139
|
|
|
54
|
-
//
|
|
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
|
-
|
|
71
|
-
clients.delete(clientId)
|
|
142
|
+
removeClient(clientId)
|
|
72
143
|
})
|
|
73
144
|
|
|
74
|
-
//
|
|
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
|
|
162
|
+
for (const [id, entry] of clients) {
|
|
90
163
|
try {
|
|
91
|
-
client.send(event, message)
|
|
92
|
-
} catch
|
|
93
|
-
|
|
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
|
-
|
|
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'
|
|
@@ -12,50 +12,25 @@
|
|
|
12
12
|
import fs from 'node:fs/promises'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import pathManager from '../infrastructure/path-manager'
|
|
15
|
+
import type {
|
|
16
|
+
ContextGeneratorConfig,
|
|
17
|
+
GitData,
|
|
18
|
+
ProjectCommands,
|
|
19
|
+
ProjectStats,
|
|
20
|
+
SyncAgentInfo,
|
|
21
|
+
} from '../types'
|
|
15
22
|
import { type ContextSources, cite, defaultSources } from '../utils/citations'
|
|
16
23
|
import * as dateHelper from '../utils/date-helper'
|
|
17
24
|
import { mergePreservedSections, validatePreserveBlocks } from '../utils/preserve-sections'
|
|
18
25
|
import { NestedContextResolver } from './nested-context-resolver'
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface ProjectStats {
|
|
30
|
-
name: string
|
|
31
|
-
version: string
|
|
32
|
-
ecosystem: string
|
|
33
|
-
projectType: string
|
|
34
|
-
fileCount: number
|
|
35
|
-
languages: string[]
|
|
36
|
-
frameworks: string[]
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface Commands {
|
|
40
|
-
install: string
|
|
41
|
-
dev: string
|
|
42
|
-
test: string
|
|
43
|
-
build: string
|
|
44
|
-
lint: string
|
|
45
|
-
format: string
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface AgentInfo {
|
|
49
|
-
name: string
|
|
50
|
-
type: 'workflow' | 'domain'
|
|
51
|
-
skill?: string
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface ContextGeneratorConfig {
|
|
55
|
-
projectId: string
|
|
56
|
-
projectPath: string
|
|
57
|
-
globalPath: string
|
|
58
|
-
}
|
|
27
|
+
export type {
|
|
28
|
+
ContextGeneratorConfig,
|
|
29
|
+
GitData,
|
|
30
|
+
ProjectCommands,
|
|
31
|
+
ProjectStats,
|
|
32
|
+
SyncAgentInfo,
|
|
33
|
+
} from '../types'
|
|
59
34
|
|
|
60
35
|
// ============================================================================
|
|
61
36
|
// CONTEXT FILE GENERATOR
|
|
@@ -103,8 +78,8 @@ export class ContextFileGenerator {
|
|
|
103
78
|
async generate(
|
|
104
79
|
git: GitData,
|
|
105
80
|
stats: ProjectStats,
|
|
106
|
-
commands:
|
|
107
|
-
agents:
|
|
81
|
+
commands: ProjectCommands,
|
|
82
|
+
agents: SyncAgentInfo[],
|
|
108
83
|
sources?: ContextSources
|
|
109
84
|
): Promise<string[]> {
|
|
110
85
|
const contextPath = path.join(this.config.globalPath, 'context')
|
|
@@ -138,8 +113,8 @@ export class ContextFileGenerator {
|
|
|
138
113
|
contextPath: string,
|
|
139
114
|
git: GitData,
|
|
140
115
|
stats: ProjectStats,
|
|
141
|
-
commands:
|
|
142
|
-
agents:
|
|
116
|
+
commands: ProjectCommands,
|
|
117
|
+
agents: SyncAgentInfo[],
|
|
143
118
|
sources?: ContextSources
|
|
144
119
|
): Promise<void> {
|
|
145
120
|
const workflowAgents = agents.filter((a) => a.type === 'workflow').map((a) => a.name)
|
|
@@ -348,8 +323,8 @@ ${
|
|
|
348
323
|
async generateMonorepoContexts(
|
|
349
324
|
git: GitData,
|
|
350
325
|
stats: ProjectStats,
|
|
351
|
-
commands:
|
|
352
|
-
agents:
|
|
326
|
+
commands: ProjectCommands,
|
|
327
|
+
agents: SyncAgentInfo[]
|
|
353
328
|
): Promise<string[]> {
|
|
354
329
|
const monoInfo = await pathManager.detectMonorepo(this.config.projectPath)
|
|
355
330
|
|
|
@@ -394,8 +369,8 @@ ${
|
|
|
394
369
|
resolvedCtx: { content: string; sources: string[]; overrides: string[] },
|
|
395
370
|
git: GitData,
|
|
396
371
|
stats: ProjectStats,
|
|
397
|
-
commands:
|
|
398
|
-
agents:
|
|
372
|
+
commands: ProjectCommands,
|
|
373
|
+
agents: SyncAgentInfo[]
|
|
399
374
|
): Promise<string> {
|
|
400
375
|
const workflowAgents = agents.filter((a) => a.type === 'workflow').map((a) => a.name)
|
|
401
376
|
const domainAgents = agents.filter((a) => a.type === 'domain').map((a) => a.name)
|