diff --git a/package.json b/package.json index 07b39bd8..8470c20e 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,6 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", "simple-git": "^3.32.3", "ssh-config": "^5.0.4", "ssh2": "^1.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5271fe5f..f2d63940 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,9 +254,6 @@ importers: remark-parse: specifier: ^11.0.0 version: 11.0.0 - remark-stringify: - specifier: ^11.0.0 - version: 11.0.0 simple-git: specifier: ^3.32.3 version: 3.32.3 diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 5db27d90..53252b55 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -649,8 +649,17 @@ export class TeamMemberLogsFinder { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`"taskId"\\s*:\\s*"${escapedTaskId}"`); + const trimmedId = taskId.trim(); + // CLI agents may use displayId (first 8 chars of UUID) in tool inputs. + // Build regex that matches either form. + const displayId = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmedId) + ? trimmedId.slice(0, 8).toLowerCase() + : null; + const idAlternation = displayId + ? `(?:${trimmedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${displayId})` + : trimmedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`"taskId"\\s*:\\s*"${idAlternation}"`); try { for await (const line of rl) { @@ -926,6 +935,17 @@ export class TeamMemberLogsFinder { const teamLower = teamName.trim().toLowerCase(); const taskIdStr = taskId.trim(); + // CLI agents often use the short displayId (first 8 chars of UUID) in tool inputs, + // while the UI passes the full UUID. Match both forms to bridge this gap. + const taskIdDisplayForm = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskIdStr) + ? taskIdStr.slice(0, 8).toLowerCase() + : null; + + const matchesTaskId = (candidate: string): boolean => + candidate === taskIdStr || + (taskIdDisplayForm !== null && candidate.toLowerCase() === taskIdDisplayForm); + const extractTaskIdFromUnknown = (raw: unknown): string | null => { if (typeof raw === 'string') return raw.trim(); if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw); @@ -1045,7 +1065,7 @@ export class TeamMemberLogsFinder { const inputTeam = extractTeamFromInput(input); const rawTaskId = input.taskId ?? input.task_id; const inputTaskId = extractTaskIdFromUnknown(rawTaskId); - if (inputTaskId && inputTaskId === taskIdStr) { + if (inputTaskId && matchesTaskId(inputTaskId)) { // If team is present in the input, require exact match. if (inputTeam) { if (inputTeam.toLowerCase() === teamLower) { diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 7a420ab2..02165f84 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -89,6 +89,21 @@ function quoteArg(arg: string): string { return arg; } +/** Env vars injected into every spawned Claude CLI process. */ +const CLI_ENV_DEFAULTS: Record = { + CLAUDE_HOOK_JUDGE_MODE: 'true', +}; + +/** Merge CLI_ENV_DEFAULTS into spawn/exec options.env (or process.env if absent). */ +function withCliEnv }>( + options: T +): T { + return { + ...options, + env: { ...(options.env ?? process.env), ...CLI_ENV_DEFAULTS }, + }; +} + /** * Execute a CLI binary, falling back to running the command through a * shell on Windows if the normal path-based spawn fails. `binaryPath` @@ -103,11 +118,12 @@ export async function execCli( options: ExecFileOptions = {} ): Promise<{ stdout: string; stderr: string }> { const target = binaryPath || 'claude'; + const opts = withCliEnv(options); // attempt the normal execFile path first if (!needsShell(target)) { try { - const result = await execFileAsync(target, args, options); + const result = await execFileAsync(target, args, opts); return { stdout: String(result.stdout), stderr: String(result.stderr) }; } catch (err: unknown) { // fall through to shell fallback only when the error matches the @@ -124,7 +140,7 @@ export async function execCli( // shell fallback (Windows only; others shouldn't reach here) const cmd = [target, ...args].map(quoteArg).join(' '); - const shellResult = await execShellAsync(cmd, options as unknown as ExecOptions); + const shellResult = await execShellAsync(cmd, opts as unknown as ExecOptions); return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) }; } @@ -139,21 +155,23 @@ export function spawnCli( args: string[], options: SpawnOptions = {} ): ReturnType { + const opts = withCliEnv(options); + if (process.platform === 'win32' && needsShell(binaryPath)) { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - return spawn(cmd, { ...options, shell: true }); + return spawn(cmd, { ...opts, shell: true }); } try { - return spawn(binaryPath, args, options); + return spawn(binaryPath, args, opts); } catch (err: unknown) { const code = err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined; if (process.platform === 'win32' && code === 'EINVAL') { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - return spawn(cmd, { ...options, shell: true }); + return spawn(cmd, { ...opts, shell: true }); } throw err; } diff --git a/src/main/utils/textFormatting.ts b/src/main/utils/textFormatting.ts index 74fd96e2..4f634639 100644 --- a/src/main/utils/textFormatting.ts +++ b/src/main/utils/textFormatting.ts @@ -1,16 +1,41 @@ import remarkParse from 'remark-parse'; -import remarkStringify from 'remark-stringify'; import stripMarkdownPlugin from 'strip-markdown'; import { unified } from 'unified'; -const processor = unified().use(remarkParse).use(stripMarkdownPlugin).use(remarkStringify); +/** + * Minimal plain-text compiler for unified. + * After strip-markdown, the MDAST contains only text nodes — + * this compiler simply concatenates their values. + * + * Replaces remark-stringify to avoid ESM→CJS interop issues + * in Electron's main process (CJS output format). + */ +function plainTextCompiler(this: ReturnType) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this as any).compiler = (tree: any): string => { + const parts: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function visit(node: any): void { + if ('value' in node && typeof node.value === 'string') { + parts.push(node.value); + } + if ('children' in node && Array.isArray(node.children)) { + node.children.forEach(visit); + } + } + visit(tree); + return parts.join(''); + }; +} + +const processor = unified().use(remarkParse).use(stripMarkdownPlugin).use(plainTextCompiler); /** * Strips markdown formatting from text for use in plain-text contexts * like native OS notifications. * * Uses remark ecosystem (strip-markdown plugin) for reliable parsing. - * Pipeline: remarkParse → stripMarkdown (transform) → remarkStringify (compile to plain text). + * Pipeline: remarkParse → stripMarkdown (transform) → plainTextCompiler (extract text). */ export function stripMarkdown(text: string): string { const result = processor.processSync(text);