mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
This captures the pending worktree fixes before applying them to the current local main. The changes tighten IM adapter path and credential handling, preserve retry behavior for failed desktop notifications, and make Azure/OpenAI provider auth and stop reasons reflect actual runtime state. Constraint: Worktree was detached from an older local main with pending uncommitted fixes Rejected: Merge the detached HEAD directly | would also replay unrelated stale history Rejected: Leave notification dedupe as fire-and-forget | failed sends consumed retry keys Confidence: high Scope-risk: broad Directive: Keep adapter absolute-path matching constrained to configured work roots Tested: git diff --check Not-tested: full quality gate before local main integration
208 lines
6.2 KiB
TypeScript
208 lines
6.2 KiB
TypeScript
import * as fs from 'node:fs'
|
|
import * as os from 'node:os'
|
|
import * as path from 'node:path'
|
|
|
|
export type RecentProject = {
|
|
projectPath: string
|
|
realPath: string
|
|
projectName: string
|
|
isGit: boolean
|
|
repoName: string | null
|
|
branch: string | null
|
|
modifiedAt: string
|
|
sessionCount: number
|
|
}
|
|
|
|
export type GitInfo = {
|
|
branch: string | null
|
|
repoName: string | null
|
|
workDir: string
|
|
changedFiles: number
|
|
}
|
|
|
|
export type SessionTask = {
|
|
id: string
|
|
subject: string
|
|
status: 'pending' | 'in_progress' | 'completed'
|
|
}
|
|
|
|
export class AdapterHttpClient {
|
|
readonly httpBaseUrl: string
|
|
private readonly allowedProjectRoots: string[]
|
|
/** Default timeout for HTTP requests (30 seconds) */
|
|
private static readonly DEFAULT_TIMEOUT_MS = 30_000
|
|
|
|
constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) {
|
|
this.httpBaseUrl = wsUrl
|
|
.replace(/^ws:/, 'http:')
|
|
.replace(/^wss:/, 'https:')
|
|
.replace(/\/$/, '')
|
|
this.allowedProjectRoots = (options?.allowedProjectRoots ?? [])
|
|
.map(resolveExistingProjectPath)
|
|
.filter((value): value is string => Boolean(value))
|
|
}
|
|
|
|
/** Create an AbortController with timeout */
|
|
private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): {
|
|
controller: AbortController
|
|
timer: ReturnType<typeof setTimeout>
|
|
} {
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
return { controller, timer }
|
|
}
|
|
|
|
async createSession(workDir: string): Promise<string> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir }),
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to create session: ${(err as any).message}`)
|
|
}
|
|
const data = (await res.json()) as { sessionId: string }
|
|
return data.sessionId
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async listRecentProjects(): Promise<RecentProject[]> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to list projects: ${res.statusText}`)
|
|
}
|
|
const data = (await res.json()) as { projects: RecentProject[] }
|
|
return data.projects
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Match a project by index (1-based) or fuzzy name from recent projects.
|
|
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
|
|
*/
|
|
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
|
|
const directPath = resolveExistingProjectPath(query)
|
|
if (directPath) {
|
|
if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) {
|
|
return {}
|
|
}
|
|
|
|
return {
|
|
project: {
|
|
projectPath: directPath,
|
|
realPath: directPath,
|
|
projectName: path.basename(directPath) || directPath,
|
|
isGit: fs.existsSync(path.join(directPath, '.git')),
|
|
repoName: null,
|
|
branch: null,
|
|
modifiedAt: new Date().toISOString(),
|
|
sessionCount: 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
const projects = await this.listRecentProjects()
|
|
|
|
// Try as 1-based index
|
|
const num = parseInt(query, 10)
|
|
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
|
|
return { project: projects[num - 1] }
|
|
}
|
|
|
|
const q = query.toLowerCase()
|
|
|
|
// Exact project name match
|
|
const exact = projects.find(p => p.projectName.toLowerCase() === q)
|
|
if (exact) return { project: exact }
|
|
|
|
// Fuzzy: name or path contains query
|
|
const matches = projects.filter(p =>
|
|
p.projectName.toLowerCase().includes(q) ||
|
|
p.realPath.toLowerCase().includes(q)
|
|
)
|
|
if (matches.length === 1) return { project: matches[0] }
|
|
if (matches.length > 1) return { ambiguous: matches }
|
|
|
|
return {}
|
|
}
|
|
|
|
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to load git info: ${(err as any).message}`)
|
|
}
|
|
return (await res.json()) as GitInfo
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
if (res.status === 404) return []
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to load tasks: ${(err as any).message}`)
|
|
}
|
|
const data = (await res.json()) as { tasks?: SessionTask[] }
|
|
return Array.isArray(data.tasks) ? data.tasks : []
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
}
|
|
|
|
function isPathWithinAllowedRoots(target: string, roots: string[]): boolean {
|
|
if (roots.length === 0) return false
|
|
|
|
for (const root of roots) {
|
|
const relative = path.relative(root, target)
|
|
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function resolveExistingProjectPath(query: string): string | null {
|
|
const trimmed = query.trim()
|
|
if (!trimmed) return null
|
|
|
|
const expanded = trimmed === '~'
|
|
? os.homedir()
|
|
: trimmed.startsWith('~/')
|
|
? path.join(os.homedir(), trimmed.slice(2))
|
|
: trimmed
|
|
|
|
if (!path.isAbsolute(expanded)) return null
|
|
|
|
try {
|
|
const realPath = fs.realpathSync(expanded)
|
|
return fs.statSync(realPath).isDirectory() ? realPath : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|