feat(FN-1800): merge fusion/fn-1800
This commit is contained in:
@@ -41,8 +41,6 @@ const promptCatalogReadyPromise = initPromptCatalog();
|
||||
|
||||
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AgentResult = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
|
||||
// Initialize the import (this runs in actual server, mocked in tests)
|
||||
|
||||
@@ -16,8 +16,6 @@ import { resolvePrompt } from "@fusion/core";
|
||||
|
||||
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AgentResult = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
|
||||
// Initialize the import (this runs in actual server, mocked in tests)
|
||||
|
||||
@@ -390,8 +390,8 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
`UPDATE ai_sessions SET status = 'awaiting_input', updatedAt = ?
|
||||
WHERE status = 'generating' AND currentQuestion IS NOT NULL`,
|
||||
)
|
||||
.run(now);
|
||||
recovered += Number((withQuestion as any).changes ?? 0);
|
||||
.run(now) as { changes?: number };
|
||||
recovered += Number(withQuestion.changes ?? 0);
|
||||
|
||||
// Sessions that were generating with no question — unrecoverable
|
||||
const withoutQuestion = this.db
|
||||
@@ -399,8 +399,8 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
`UPDATE ai_sessions SET status = 'error', error = 'Session interrupted — please restart', updatedAt = ?
|
||||
WHERE status = 'generating' AND currentQuestion IS NULL`,
|
||||
)
|
||||
.run(now);
|
||||
recovered += Number((withoutQuestion as any).changes ?? 0);
|
||||
.run(now) as { changes?: number };
|
||||
recovered += Number(withoutQuestion.changes ?? 0);
|
||||
|
||||
if (recovered > 0) {
|
||||
console.log(`[ai-session-store] Recovered ${recovered} stale sessions after restart`);
|
||||
|
||||
@@ -19,8 +19,8 @@ import type {
|
||||
} from "@fusion/core";
|
||||
import { summarizeTitle } from "@fusion/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Response } from "express";
|
||||
import { SessionEventBuffer, writeSSEEvent, safeWriteSSE, formatSSEEvent } from "./sse-buffer.js";
|
||||
|
||||
import { SessionEventBuffer } from "./sse-buffer.js";
|
||||
|
||||
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -297,13 +297,13 @@ export class ChatManager {
|
||||
}
|
||||
|
||||
// Persist user message
|
||||
let userMessageId: string;
|
||||
let _userMessageId: string;
|
||||
try {
|
||||
const userMessage = this.chatStore.addMessage(sessionId, {
|
||||
role: "user",
|
||||
content,
|
||||
});
|
||||
userMessageId = userMessage.id;
|
||||
_userMessageId = userMessage.id;
|
||||
} catch (err) {
|
||||
chatStreamManager.broadcast(sessionId, {
|
||||
type: "error",
|
||||
@@ -332,7 +332,7 @@ export class ChatManager {
|
||||
if (title) {
|
||||
this.chatStore.updateSession(sessionId, { title });
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Fallback on any error
|
||||
const fallback = content.trim().slice(0, 60).trim();
|
||||
if (fallback) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { join, resolve, relative, dirname, basename } from "node:path";
|
||||
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, copyFile as fsCopyFile, rename as fsRename, rm as fsRm, mkdir } from "node:fs/promises";
|
||||
import { existsSync, createReadStream, statSync } from "node:fs";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createGzip } from "node:zlib";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
|
||||
/**
|
||||
@@ -67,30 +65,6 @@ export class FileServiceError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text file extensions set.
|
||||
*/
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".txt", ".md", ".markdown",
|
||||
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
||||
".json", ".jsonc",
|
||||
".css", ".scss", ".sass", ".less",
|
||||
".html", ".htm", ".xml", ".svg",
|
||||
".yaml", ".yml",
|
||||
".toml",
|
||||
".ini", ".cfg", ".conf", ".config",
|
||||
".sh", ".bash", ".zsh", ".fish",
|
||||
".py", ".rb", ".php", ".pl", ".perl",
|
||||
".java", ".kt", ".scala", ".groovy",
|
||||
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh",
|
||||
".cs", ".fs", ".fsx",
|
||||
".go", ".rs", ".swift",
|
||||
".sql",
|
||||
".dockerfile", ".env", ".envrc", ".nvmrc",
|
||||
".gitignore", ".gitattributes", ".editorconfig",
|
||||
".lock", ".log",
|
||||
]);
|
||||
|
||||
export type WorkspaceId = "project" | string;
|
||||
|
||||
/**
|
||||
@@ -107,8 +81,9 @@ async function getTaskBasePath(store: TaskStore, taskId: string): Promise<string
|
||||
// Fall back to task directory
|
||||
const rootDir = store.getRootDir();
|
||||
return resolve(join(rootDir, ".fusion", "tasks", taskId));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT" || err.message?.includes("not found")) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT" || (error.message && error.message.includes("not found"))) {
|
||||
throw new FileServiceError(`Task ${taskId} not found`, "ENOTASK");
|
||||
}
|
||||
throw err;
|
||||
@@ -181,8 +156,9 @@ async function listFilesForBasePath(basePath: string, subPath?: string): Promise
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(targetPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -222,11 +198,12 @@ async function listFilesForBasePath(basePath: string, subPath?: string): Promise
|
||||
path: relativeBase || ".",
|
||||
entries: fileNodes,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
if (error.code === "EACCES" || error.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${subPath || "."}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
@@ -243,8 +220,9 @@ async function readFileForBasePath(basePath: string, filePath: string): Promise<
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(resolvedPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -266,11 +244,12 @@ async function readFileForBasePath(basePath: string, filePath: string): Promise<
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
if (error.code === "EACCES" || error.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
@@ -294,8 +273,9 @@ async function writeFileForBasePath(basePath: string, filePath: string, content:
|
||||
if (stats.isDirectory()) {
|
||||
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -306,8 +286,9 @@ async function writeFileForBasePath(basePath: string, filePath: string, content:
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -322,11 +303,12 @@ async function writeFileForBasePath(basePath: string, filePath: string, content:
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
if (error.code === "EACCES" || error.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
@@ -527,8 +509,9 @@ export async function copyWorkspaceFile(
|
||||
let sourceStats;
|
||||
try {
|
||||
sourceStats = await stat(resolvedSource);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Source not found: ${sourcePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -538,8 +521,9 @@ export async function copyWorkspaceFile(
|
||||
try {
|
||||
await stat(resolvedDest);
|
||||
throw new FileServiceError(`Destination already exists: ${destinationPath}`, "EEXIST");
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code !== "ENOENT") {
|
||||
if (err instanceof FileServiceError) throw err;
|
||||
}
|
||||
// ENOENT is expected - destination should not exist
|
||||
@@ -552,8 +536,9 @@ export async function copyWorkspaceFile(
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError("Destination parent is not a directory", "ENOTDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError("Destination parent directory does not exist", "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -566,9 +551,10 @@ export async function copyWorkspaceFile(
|
||||
await copyDirectoryRecursive(resolvedSource, resolvedDest);
|
||||
}
|
||||
return { success: true, message: `Copied "${sourcePath}" to "${destinationPath}"` };
|
||||
} catch (err: any) {
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${err.message}`, "EACCES");
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string; message?: string };
|
||||
if (error.code === "EACCES" || error.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${error.message}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -603,8 +589,9 @@ export async function moveWorkspaceFile(
|
||||
// Verify source exists
|
||||
try {
|
||||
await stat(resolvedSource);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Source not found: ${sourcePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -614,8 +601,9 @@ export async function moveWorkspaceFile(
|
||||
try {
|
||||
await stat(resolvedDest);
|
||||
throw new FileServiceError(`Destination already exists: ${destinationPath}`, "EEXIST");
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code !== "ENOENT") {
|
||||
if (err instanceof FileServiceError) throw err;
|
||||
}
|
||||
}
|
||||
@@ -627,8 +615,9 @@ export async function moveWorkspaceFile(
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError("Destination parent is not a directory", "ENOTDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError("Destination parent directory does not exist", "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -637,11 +626,12 @@ export async function moveWorkspaceFile(
|
||||
try {
|
||||
await fsRename(resolvedSource, resolvedDest);
|
||||
return { success: true, message: `Moved "${sourcePath}" to "${destinationPath}"` };
|
||||
} catch (err: any) {
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${err.message}`, "EACCES");
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string; message?: string };
|
||||
if (error.code === "EACCES" || error.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${error.message}`, "EACCES");
|
||||
}
|
||||
if (err.code === "EXDEV") {
|
||||
if (error.code === "EXDEV") {
|
||||
// Cross-device move: copy then delete
|
||||
await copyWorkspaceFile(store, workspace, sourcePath, destinationPath);
|
||||
await deleteWorkspaceFile(store, workspace, sourcePath);
|
||||
@@ -683,8 +673,9 @@ export async function deleteWorkspaceFile(
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(resolvedPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -697,8 +688,9 @@ export async function deleteWorkspaceFile(
|
||||
await fsRm(resolvedPath);
|
||||
}
|
||||
return { success: true, message: `Deleted "${filePath}"` };
|
||||
} catch (err: any) {
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "EACCES" || error.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
@@ -746,8 +738,9 @@ export async function renameWorkspaceFile(
|
||||
// Verify source exists
|
||||
try {
|
||||
await stat(resolvedPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -770,8 +763,9 @@ export async function renameWorkspaceFile(
|
||||
try {
|
||||
await stat(destPath);
|
||||
throw new FileServiceError(`A file or directory named "${newName}" already exists`, "EEXIST");
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code !== "ENOENT") {
|
||||
if (err instanceof FileServiceError) throw err;
|
||||
}
|
||||
}
|
||||
@@ -779,8 +773,9 @@ export async function renameWorkspaceFile(
|
||||
try {
|
||||
await fsRename(resolvedPath, destPath);
|
||||
return { success: true, message: `Renamed to "${newName}"` };
|
||||
} catch (err: any) {
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "EACCES" || error.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
@@ -818,8 +813,9 @@ export async function getWorkspaceFileForDownload(
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(resolvedPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
@@ -870,8 +866,9 @@ export async function getWorkspaceFolderForZip(
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(resolvedPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${dirPath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface GitHubPollingServiceOptions {
|
||||
rateLimiter?: GitHubRateLimiter;
|
||||
}
|
||||
|
||||
export interface GitHubPollingServiceEvents {}
|
||||
export type GitHubPollingServiceEvents = Record<string, never>;
|
||||
|
||||
interface RepoBatchConsumer {
|
||||
taskId: string;
|
||||
@@ -315,8 +315,9 @@ export class GitHubPollingService extends EventEmitter<GitHubPollingServiceEvent
|
||||
let task;
|
||||
try {
|
||||
task = await this.store.getTask(taskId);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") {
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (error.code === "ENOENT") {
|
||||
this.unwatchTask(taskId);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { IssueInfo, PrInfo } from "@fusion/core";
|
||||
import { GitHubClient } from "./github.js";
|
||||
|
||||
// Module-level cache for the GitHub App private key
|
||||
// undefined = not yet read, null = read failed, string = cached key
|
||||
|
||||
@@ -21,7 +21,6 @@ import { EventEmitter } from "node:events";
|
||||
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
|
||||
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
|
||||
import {
|
||||
parseMissionAgentResponse,
|
||||
extractJsonCandidate,
|
||||
repairJson,
|
||||
} from "./mission-interview.js";
|
||||
@@ -48,7 +47,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch (parseErr) {
|
||||
} catch (_parseErr) {
|
||||
try {
|
||||
const repaired = repairJson(candidate);
|
||||
parsed = JSON.parse(repaired);
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Router, type Request, type Response } from "express";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join, isAbsolute, dirname, basename } from "node:path";
|
||||
import type {
|
||||
PluginInstallation,
|
||||
PluginLoader,
|
||||
PluginStore,
|
||||
PluginContext,
|
||||
|
||||
@@ -11,10 +11,10 @@ import { createSSE } from "./sse.js";
|
||||
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
|
||||
import { ApiError, sendErrorResponse } from "./api-error.js";
|
||||
import { getOrCreateProjectStore, evictAllProjectStores, setOnProjectFirstCreated } from "./project-store-resolver.js";
|
||||
import { getTerminalService, type TerminalSession, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
||||
import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
import { parseBadgeUrl } from "./github.js";
|
||||
|
||||
import { WebSocketManager, type BadgeSnapshot } from "./websocket.js";
|
||||
import type { BadgePubSub } from "./badge-pubsub.js";
|
||||
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
|
||||
@@ -361,8 +361,8 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
createSSE(scopedStore, scopedStore.getMissionStore(), aiSessionStore, scopedStore.getPluginStore(), {
|
||||
projectId,
|
||||
})(req, res);
|
||||
} catch (err: any) {
|
||||
sendErrorResponse(res, 500, err.message ?? "Failed to open project event stream");
|
||||
} catch (err: unknown) {
|
||||
sendErrorResponse(res, 500, err instanceof Error ? err.message : "Failed to open project event stream");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -397,7 +397,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
let scopedStore: TaskStore;
|
||||
try {
|
||||
scopedStore = await resolveProjectScopedStore(projectId);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: "Failed to resolve project store" })}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface PluginLifecyclePayload {
|
||||
function mapSourceEventToTransition(
|
||||
sourceEvent: string,
|
||||
plugin: PluginInstallation,
|
||||
previousState?: PluginState,
|
||||
_previousState?: PluginState,
|
||||
): PluginLifecycleTransition {
|
||||
switch (sourceEvent) {
|
||||
case "plugin:registered":
|
||||
@@ -198,7 +198,7 @@ export function createSSE(
|
||||
};
|
||||
|
||||
// --- Event handler definitions ---
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- EventEmitter handlers receive untyped event data */
|
||||
const onCreated = (task: any) => {
|
||||
send(`event: task:created\ndata: ${JSON.stringify(stripTaskListHeavyFields(task))}\n\n`);
|
||||
};
|
||||
@@ -287,6 +287,8 @@ export function createSSE(
|
||||
send(`event: ai_session:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// --- Unified plugin lifecycle handler ---
|
||||
// Instead of emitting individual plugin events, we normalize all plugin
|
||||
// lifecycle changes into a single `plugin:lifecycle` SSE event with
|
||||
|
||||
@@ -50,7 +50,16 @@ export type SubtaskStreamCallback = (event: SubtaskStreamEvent, eventId?: number
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
const sessions = new Map<string, SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string }>();
|
||||
/** Minimal interface for the agent object created by createKbAgent */
|
||||
interface SubtaskAgent {
|
||||
session: {
|
||||
dispose?: () => void;
|
||||
prompt: (input: string) => Promise<unknown>;
|
||||
state: { messages: Array<{ role: string; content?: string | Array<{ type: string; text: string }> }> };
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = new Map<string, SubtaskSession & { updatedAt: Date; agent?: SubtaskAgent; thinkingOutput: string }>();
|
||||
|
||||
// ── AI Session Persistence ────────────────────────────────────────────────
|
||||
|
||||
@@ -69,7 +78,7 @@ export function setAiSessionStore(store: AiSessionStore): void {
|
||||
_aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
|
||||
type SubtaskInternalSession = SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string; projectId?: string };
|
||||
type SubtaskInternalSession = SubtaskSession & { updatedAt: Date; agent?: SubtaskAgent; thinkingOutput: string; projectId?: string };
|
||||
|
||||
function safeParseJson<T>(
|
||||
text: string | null,
|
||||
|
||||
@@ -77,7 +77,7 @@ async function loadPtyModule(): Promise<typeof import("node-pty")> {
|
||||
if (existsSync(nativePath)) {
|
||||
try {
|
||||
const nativeModule: { exports?: unknown } = { exports: {} };
|
||||
// @ts-ignore - process.dlopen is Node internal
|
||||
// process.dlopen is a Node internal API
|
||||
process.dlopen(nativeModule, nativePath);
|
||||
console.log("[terminal] Pre-loaded native module via dlopen");
|
||||
} catch (dlopenErr) {
|
||||
|
||||
@@ -216,6 +216,7 @@ function httpsRequest(
|
||||
/**
|
||||
* Decode JWT payload without verification
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JWT payloads are untyped
|
||||
function decodeJwtPayload(token: string): any {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
@@ -248,7 +249,9 @@ async function readAuthKeyFromFile(authPath: string, provider: string): Promise<
|
||||
if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) {
|
||||
return entry.key;
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// No auth file or invalid format - fall through to return null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -259,19 +262,25 @@ async function readAuthKeyFromFile(authPath: string, provider: string): Promise<
|
||||
async function readConfiguredApiKey(provider: string, authStorage?: AuthStorageLike): Promise<string | null> {
|
||||
try {
|
||||
authStorage?.reload();
|
||||
} catch {}
|
||||
} catch {
|
||||
// Reload may fail if no storage - ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await authStorage?.getApiKey?.(provider);
|
||||
if (apiKey) return apiKey;
|
||||
} catch {}
|
||||
} catch {
|
||||
// getApiKey may not be implemented - ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = authStorage?.get?.(provider);
|
||||
if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) {
|
||||
return entry.key;
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// get() may not be implemented - ignore
|
||||
}
|
||||
|
||||
for (const authPath of getAuthFileCandidates()) {
|
||||
const apiKey = await readAuthKeyFromFile(authPath, provider);
|
||||
@@ -287,6 +296,7 @@ async function readConfiguredApiKey(provider: string, authStorage?: AuthStorageL
|
||||
* Read Claude credentials from macOS keychain.
|
||||
* Returns the parsed credentials object or null if not found/error.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped keychain data
|
||||
async function readClaudeKeychainCredentials(): Promise<any | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
@@ -401,27 +411,36 @@ export function _resetSleepFn(): void {
|
||||
export function _stripClaudeAnsi(text: string): string {
|
||||
let clean = text
|
||||
// Cursor forward (CSI n C): replace with n spaces
|
||||
// eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence
|
||||
.replace(/\x1B\[(\d+)C/g, (_m, n) => " ".repeat(parseInt(n, 10)))
|
||||
// Cursor movement (up/down/back/position)
|
||||
// eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence
|
||||
.replace(/\x1B\[\d*[ABD]/g, "")
|
||||
// eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence
|
||||
.replace(/\x1B\[\d+;\d+[Hf]/g, "\n")
|
||||
// Remaining CSI sequences (colors, modes, etc.)
|
||||
// eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence
|
||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, "")
|
||||
// OSC sequences
|
||||
// eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence
|
||||
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, "")
|
||||
// Other ESC sequences
|
||||
// eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence
|
||||
.replace(/\x1B[A-Za-z]/g, "")
|
||||
// Carriage returns
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
// Handle backspaces
|
||||
/* eslint-disable no-control-regex -- backspace control character */
|
||||
while (clean.includes("\x08")) {
|
||||
clean = clean.replace(/[^\x08]\x08/, "");
|
||||
clean = clean.replace(/^\x08+/, "");
|
||||
}
|
||||
/* eslint-enable no-control-regex */
|
||||
|
||||
// Strip remaining non-printable control characters (except newline)
|
||||
// eslint-disable-next-line no-control-regex -- control character cleanup
|
||||
clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "");
|
||||
return clean;
|
||||
}
|
||||
@@ -585,7 +604,7 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
? ["/c", "claude", "--add-dir", cwd]
|
||||
: ["-c", `claude --add-dir "${cwd}"`];
|
||||
|
||||
const ptyOptions: any = {
|
||||
const ptyOptions: Record<string, unknown> = {
|
||||
name: "xterm-256color",
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
@@ -606,7 +625,9 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { ptyProcess.kill(); } catch {}
|
||||
try { ptyProcess.kill(); } catch {
|
||||
// Kill may fail if process already exited - ignore
|
||||
}
|
||||
// Return whatever we have if it contains usage data
|
||||
const clean = _stripClaudeAnsi(buf);
|
||||
if (clean.includes("Current session") || clean.includes("% left") || clean.includes("% used")) {
|
||||
@@ -631,7 +652,9 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try { ptyProcess.kill(); } catch {}
|
||||
try { ptyProcess.kill(); } catch {
|
||||
// Kill may fail if process already exited - ignore
|
||||
}
|
||||
reject(new Error("Claude CLI auth error"));
|
||||
return;
|
||||
}
|
||||
@@ -682,7 +705,9 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try { ptyProcess.kill(); } catch {}
|
||||
try { ptyProcess.kill(); } catch {
|
||||
// Kill may fail if process already exited - ignore
|
||||
}
|
||||
resolve(buf);
|
||||
}
|
||||
}, 2000);
|
||||
@@ -777,9 +802,9 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
usage.status = "error";
|
||||
usage.error = "Could not parse usage from CLI output";
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "CLI fallback failed";
|
||||
usage.error = e instanceof Error ? e.message : "CLI fallback failed";
|
||||
}
|
||||
|
||||
return usage;
|
||||
@@ -807,12 +832,15 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
path.join(process.env.HOME || "~", ".config", "claude", ".credentials.json"),
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped credentials JSON
|
||||
let creds: any = null;
|
||||
for (const p of credPaths) {
|
||||
try {
|
||||
creds = JSON.parse(await readFile(p, "utf-8"));
|
||||
break;
|
||||
} catch {}
|
||||
} catch {
|
||||
// File doesn't exist or invalid JSON - continue to next path
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to macOS keychain if file credentials not found
|
||||
@@ -941,6 +969,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
* Get a window data object from the API response, checking multiple possible keys
|
||||
* for backward compatibility (API may use `session` instead of `five_hour`).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
|
||||
const getWindowData = (primaryKey: string, fallbackKeys: string[] = []): any => {
|
||||
if (data[primaryKey] && typeof data[primaryKey] === "object") {
|
||||
return data[primaryKey];
|
||||
@@ -999,9 +1028,9 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
if (sevenDay) usage.windows.push(sevenDay);
|
||||
if (sonnet) usage.windows.push(sonnet);
|
||||
if (opus) usage.windows.push(opus);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch Claude usage";
|
||||
usage.error = e instanceof Error ? e.message : "Failed to fetch Claude usage";
|
||||
}
|
||||
|
||||
return usage;
|
||||
@@ -1021,6 +1050,7 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
const codexHome = process.env.CODEX_HOME || path.join(process.env.HOME || "~", ".codex");
|
||||
const authPath = path.join(codexHome, "auth.json");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped auth JSON
|
||||
let auth: any = null;
|
||||
try {
|
||||
auth = JSON.parse(await readFile(authPath, "utf-8"));
|
||||
@@ -1074,6 +1104,7 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
if (data.email) usage.email = data.email;
|
||||
if (data.plan_type) usage.plan = data.plan_type.charAt(0).toUpperCase() + data.plan_type.slice(1);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
|
||||
const parseWindow = (win: any, label: string): UsageWindow | null => {
|
||||
if (!win || typeof win !== "object") return null;
|
||||
const pctUsed: number = win.used_percent ?? 0;
|
||||
@@ -1112,9 +1143,9 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
if (primary) usage.windows.push(primary);
|
||||
if (secondary) usage.windows.push(secondary);
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
usage.error = e instanceof Error ? e.message : "Failed to fetch";
|
||||
}
|
||||
|
||||
return usage;
|
||||
@@ -1132,6 +1163,7 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
||||
|
||||
// Load Gemini OAuth credentials
|
||||
const oauthPath = path.join(process.env.HOME || "~", ".gemini", "oauth_creds.json");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped OAuth JSON
|
||||
let oauthCreds: any = null;
|
||||
try {
|
||||
oauthCreds = JSON.parse(await readFile(oauthPath, "utf-8"));
|
||||
@@ -1161,7 +1193,9 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
||||
usage.error = `Unsupported auth type: ${authType} (need oauth-personal)`;
|
||||
return usage;
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Settings file doesn't exist or invalid JSON - continue
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await httpsRequest(
|
||||
@@ -1192,6 +1226,7 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
||||
usage.status = "ok";
|
||||
|
||||
// Parse buckets array
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
|
||||
const buckets: any[] = data.buckets || [];
|
||||
if (Array.isArray(buckets) && buckets.length > 0) {
|
||||
// Group by model family, pick lowest remainingFraction per family
|
||||
@@ -1250,9 +1285,9 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
usage.error = e instanceof Error ? e.message : "Failed to fetch";
|
||||
}
|
||||
|
||||
return usage;
|
||||
@@ -1300,6 +1335,7 @@ async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise<Provide
|
||||
usage.status = "ok";
|
||||
|
||||
// Parse model_remains array — group by model family
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
|
||||
const modelRemains: any[] = data?.model_remains || [];
|
||||
if (Array.isArray(modelRemains) && modelRemains.length > 0) {
|
||||
for (const model of modelRemains) {
|
||||
@@ -1344,9 +1380,9 @@ async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise<Provide
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
usage.error = e instanceof Error ? e.message : "Failed to fetch";
|
||||
}
|
||||
|
||||
return usage;
|
||||
@@ -1400,10 +1436,11 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
|
||||
|
||||
usage.status = "ok";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
|
||||
const limits: any[] = data?.data?.limits || [];
|
||||
|
||||
// Find TOKENS_LIMIT (5-hour rolling window)
|
||||
const tokensLimit = limits.find((l: any) => l.type === "TOKENS_LIMIT");
|
||||
const tokensLimit = limits.find((l) => l.type === "TOKENS_LIMIT");
|
||||
if (tokensLimit) {
|
||||
const percentage: number = tokensLimit.percentage ?? 0;
|
||||
// The percentage field represents percentage USED
|
||||
@@ -1439,11 +1476,12 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
|
||||
}
|
||||
|
||||
// Find TIME_LIMIT (MCP monthly search quota)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
|
||||
const timeLimit = limits.find((l: any) => l.type === "TIME_LIMIT");
|
||||
if (timeLimit) {
|
||||
const total: number = timeLimit.usage ?? 0;
|
||||
const used: number = timeLimit.currentValue ?? 0;
|
||||
const remaining: number = timeLimit.remaining ?? Math.max(0, total - used);
|
||||
const _remaining: number = timeLimit.remaining ?? Math.max(0, total - used);
|
||||
const percentage: number = timeLimit.percentage ?? 0;
|
||||
|
||||
let resetText: string | null = null;
|
||||
@@ -1472,9 +1510,9 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
|
||||
if (data?.data?.level) {
|
||||
usage.plan = data.data.level.charAt(0).toUpperCase() + data.data.level.slice(1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
usage.error = e instanceof Error ? e.message : "Failed to fetch";
|
||||
}
|
||||
|
||||
return usage;
|
||||
@@ -1521,13 +1559,13 @@ export function withTimeout(
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
.catch((err: unknown) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
name: providerName,
|
||||
icon: "⏱️",
|
||||
status: "error",
|
||||
error: err.message || "Failed",
|
||||
error: err instanceof Error ? err.message : "Failed",
|
||||
windows: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,17 +386,6 @@ function extractPartsFromChannel(channel: string): { taskId: string | null; proj
|
||||
return { projectId: null, taskId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract taskId from any badge channel key (without knowing the projectId).
|
||||
* @deprecated Use extractPartsFromChannel instead
|
||||
*/
|
||||
function extractTaskIdFromChannel(channel: string): string | null {
|
||||
// Channel format: badge:{projectId}:{taskId}
|
||||
// We need to find the taskId after the second colon
|
||||
const match = channel.match(/^badge:[^:]+:(.+)$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseClientMessage(raw: WebSocket.RawData):
|
||||
| { ok: true; value: BadgeClientMessage }
|
||||
| { ok: false; error: string } {
|
||||
|
||||
Reference in New Issue
Block a user