Skip to content

feat(harness): multi-harness adapter pattern for analysis providers#54

Open
serrrfirat wants to merge 5 commits into
mainfrom
dmux-1771586164544
Open

feat(harness): multi-harness adapter pattern for analysis providers#54
serrrfirat wants to merge 5 commits into
mainfrom
dmux-1771586164544

Conversation

@serrrfirat

@serrrfirat serrrfirat commented Feb 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Introduces a pluggable harness adapter system supporting 6 analysis providers: Claude Code, OpenClaw, Codex, Cursor Agent, OpenCode, and Amp — with ordered fallback chains
  • Consolidates ~1000 lines of duplicated provider-branching logic from src/index.ts and src/scripts/daemon.ts into a single shared runAnalysisWithHarness() entrypoint
  • Adds --analysis-harness config option (e.g. synapse config --analysis-harness codex,claude_code) with full backward compatibility for the legacy --analysis-provider flag

New files

File Purpose
src/harness/types.ts HarnessAdapter interface, HarnessId type, run options/result types
src/harness/parsers.ts NDJSON output parsers for all 6 harness output formats
src/harness/index.ts Registry, resolveHarnessChain(), runAnalysisWithHarness()
src/harness/adapters/*.ts One adapter per provider (claude-code, openclaw, codex, cursor, opencode, amp)
src/harness/__tests__/*.ts 38 tests covering parsers, chain resolution, and registry

Robustness hardening (from self-review)

  • child.on('error') handlers on all adapters to prevent Promise hangs when spawn fails
  • child.stdin.on('error') EPIPE guards on stdin-piping adapters
  • CLI argument size validation for cursor/opencode (macOS execve ~128KB limit)
  • Empty harness chain guard in runAnalysisWithHarness()
  • Resolved double-guard pattern to prevent multiple resolve() calls

Test plan

  • npx tsc --noEmit passes
  • bun test — 93 tests pass (38 new + 55 existing), 0 failures
  • synapse config --analysis-harness codex,claude_code sets chain correctly
  • synapse config (show) displays harness chain and effective chain
  • synapse analyze with default config uses Claude Code (backward compat)
  • Fallback: if first harness unavailable, second is tried

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Configurable AI tool fallback chains via --analysis-harness option for analysis runs
    • Support for multiple AI tools (Claude Code, OpenClaw, Codex, Cursor, OpenCode, Amp) with automatic fallback selection
    • Enhanced CLI visibility displaying active tool chain and session metadata
  • Bug Fixes

    • Improved session management and recovery for context overflow scenarios
    • Better error handling and fallback mechanics across tool invocations

Introduce a pluggable harness adapter system supporting Claude Code, OpenClaw,
Codex, Cursor Agent, OpenCode, and Amp — with ordered fallback chains. This
consolidates ~1000 lines of duplicated provider-branching logic from index.ts
and daemon.ts into a single shared `runAnalysisWithHarness()` entrypoint.

Key changes:
- New `src/harness/` module: types, parsers, registry, chain resolver, runner
- 6 adapter implementations with spawn error handling and EPIPE guards
- CLI arg size validation for cursor/opencode (OS execve limit)
- `--analysis-harness` config option with backward-compat `analysisProvider`
- Harness invocation telemetry (`analysis_harness_invocation` event)
- 93 tests passing (38 new harness tests + 55 existing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Feb 20, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Introduces a harness-based orchestration system for executing multiple AI code analysis tools (Claude Code, OpenClaw, Codex, Cursor, OpenCode, Amp). Adds tool-specific adapters, a shared registry and executor, output parsers, and integrates harness chain resolution into core configuration and analysis flows, replacing the previous provider-centric approach.

Changes

Cohort / File(s) Summary
Harness Type System
src/harness/types.ts
Defines HarnessId union type ('claude_code', 'openclaw', 'codex', 'cursor', 'opencode', 'amp'), HarnessRunOptions interface (prompt, timeoutSeconds, verbose, sessionId, cwd), HarnessRunResult structure (ok, rawText, error, durationMs, metadata, tokenUsage, contextOverflow), and HarnessAdapter interface (id, displayName, findBinary(), isAvailable(), run()).
Output Parsers
src/harness/parsers.ts
Provides shared parsing utilities for tool-specific NDJSON and JSON outputs: parseNdjsonLines, extractTextFromClaudeJson, extractTextFromClaudeStreamJson, extractTextFromCodexOutput, extractTextFromCursorOutput, extractTextFromOpenCodeOutput, extractTextFromAmpOutput. Handles token usage, context overflow detection, and fallback text extraction strategies.
Tool Adapters
src/harness/adapters/claude-code.ts, ...openclaw.ts, ...codex.ts, ...cursor.ts, ...opencode.ts, ...amp.ts
Six HarnessAdapter implementations. Each discovers tool binary via environment variable or default paths, checks availability, spawns process with tool-specific arguments (stream flags, JSON output modes), feeds prompt to stdin, enforces timeout, handles errors, and returns HarnessRunResult with extracted text and metadata. OpenCode and Cursor enforce prompt size limits; Claude supports session resumption and verbose mode; OpenClaw delegates to runOpenClawAnalysis.
Harness Orchestration
src/harness/index.ts
Implements global adapters registry, automatic registration of built-in adapters, resolveHarnessChain function (maps config properties to HarnessId array with backward compatibility), and runAnalysisWithHarness executor that iterates through chain, skips unavailable adapters, runs first successful or partial result, with robust error collection. Exports registry helpers (registerHarness, getHarness, getAvailableHarnesses).
Harness Tests
src/harness/__tests__/index.test.ts, ...parsers.test.ts
Comprehensive test suites for resolveHarnessChain (explicit/auto precedence, fallback logic, empty chain), harness registry validation (all six adapters registered, unknown throws error, adapter methods present), and all output parsers (valid/invalid NDJSON, token usage, context overflow, fallback text strategies).
Configuration
src/config.ts
Imports HarnessId type, adds optional analysisHarness: HarnessId[] property to SynapseConfig for ordered fallback chain. Updates analysisProvider comment noting legacy context. No behavioral changes to loading/saving.
Integration
src/index.ts, src/scripts/daemon.ts
Replaces provider-centric analysis (resolveAnalysisProvider, runOpenClawAnalysis) with harness-based flow (resolveHarnessChain, runAnalysisWithHarness). Adds extractAnalysisFromResponseText for parsing harness output. Updates rankInsightsForDisplay provider parameter from union type to string. daemon.ts adds session management via harness results, context overflow retry logic with one-hour guard, detailed harness usage telemetry (provider, model, duration, errors, fallback status), and validates harness chain config. CLI displays harness chain and fallback status.
Maintenance
.dmux-hooks/examples/post_pr.example, .gitignore
Removes example post_pr hook script. Adds .dmux-hooks/ to gitignore.

Sequence Diagram

sequenceDiagram
    participant Client as Client/Daemon
    participant Config as Config
    participant Registry as Harness Registry
    participant Chain as Chain Executor
    participant Adapter as Tool Adapter
    participant Tool as External Tool
    participant Parser as Output Parser

    Client->>Config: Get analysisHarness or analysisProvider
    Config-->>Client: Return harness chain
    Client->>Registry: resolveHarnessChain(config, openclawEnabled)
    Registry-->>Client: HarnessId[] (fallback chain)
    Client->>Chain: runAnalysisWithHarness(prompt, harnessChain)
    
    loop For each harness in chain
        Chain->>Adapter: getHarness(id)
        Registry-->>Chain: HarnessAdapter instance
        Chain->>Adapter: isAvailable()
        alt Unavailable
            Adapter-->>Chain: false
            Chain->>Chain: Skip to next harness
        else Available
            Adapter-->>Chain: true
            Chain->>Adapter: run({prompt, timeout})
            Adapter->>Tool: findBinary() → spawn with args
            Adapter->>Tool: Feed prompt to stdin
            Tool-->>Adapter: stdout/stderr
            Adapter->>Parser: extractText(stdout)
            Parser-->>Adapter: Parsed text & metadata
            Adapter-->>Chain: HarnessRunResult {ok, rawText, metadata}
            alt Success or Partial Result
                Chain-->>Client: Return result with harness used
                Note over Chain: Stop iteration on success/partial
            else Failure
                Chain->>Chain: Collect error, continue loop
            end
        end
    end
    
    alt All harnesses failed/unavailable
        Chain-->>Client: Error result
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Six tools now dance in harmonious chain,
No single path left bare—a fallback refrain!
From Claude to OpenCode, each adapter springs,
With binary finders and session-aware wings,
The harness orchestrates, parsing with care,
Analysis blooming from tools everywhere! 🌟

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the main change: introduction of a multi-harness adapter pattern for analysis providers, which is the primary objective of this PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dmux-1771586164544

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello @serrrfirat, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly refactors the analysis provider system by introducing a flexible, pluggable harness architecture. This change allows for seamless integration and ordered fallback across multiple AI analysis tools, centralizing previously dispersed logic and enhancing the system's configurability and resilience. The new pattern simplifies the addition of future analysis providers and improves the overall robustness of the analysis process.

Highlights

  • Multi-Harness Adapter Pattern: Introduced a pluggable harness adapter system supporting six analysis providers: Claude Code, OpenClaw, Codex, Cursor Agent, OpenCode, and Amp, with ordered fallback chains.
  • Code Consolidation: Consolidated approximately 1000 lines of duplicated provider-branching logic from src/index.ts and src/scripts/daemon.ts into a single shared runAnalysisWithHarness() entrypoint.
  • New Configuration Option: Added a new --analysis-harness configuration option for specifying the fallback chain, while maintaining backward compatibility for the legacy --analysis-provider flag.
  • Robustness Hardening: Implemented child.on('error') handlers, child.stdin.on('error') EPIPE guards, CLI argument size validation for specific adapters, and an empty harness chain guard to improve system stability.
  • Telemetry Enhancements: Updated insight ranking telemetry to record detailed metrics for all ranked, selected, and suppressed insight candidates, including bias mode and provider information.
Changelog
  • src/api-server.ts
    • Removed parseInsightBiasMode function, which was moved to src/config.ts.
  • src/config.ts
    • Imported HarnessId type for new configuration.
    • Added analysisHarness optional array to SynapseConfig for specifying an ordered fallback chain of analysis providers.
    • Moved and exported parseInsightBiasMode function from src/api-server.ts.
  • src/harness/tests/index.test.ts
    • Added new test file for resolveHarnessChain, getHarness, and getAvailableHarnesses functions, covering various configuration scenarios and harness registry behavior.
  • src/harness/tests/parsers.test.ts
    • Added new test file for NDJSON parsing and text extraction functions specific to Claude, Codex, Cursor, OpenCode, and Amp outputs.
  • src/harness/adapters/amp.ts
    • Added new file implementing the AmpAdapter for integrating the Amp analysis tool, including binary discovery, availability checks, and execution logic.
  • src/harness/adapters/claude-code.ts
    • Added new file implementing the ClaudeCodeAdapter for integrating the Claude Code analysis tool, handling verbose and non-verbose output formats and session management.
  • src/harness/adapters/codex.ts
    • Added new file implementing the CodexAdapter for integrating the Codex analysis tool, including binary discovery and execution logic.
  • src/harness/adapters/cursor.ts
    • Added new file implementing the CursorAdapter for integrating the Cursor Agent analysis tool, including binary discovery, argument size validation, and execution logic.
  • src/harness/adapters/openclaw.ts
    • Added new file implementing the OpenClawAdapter for integrating the OpenClaw analysis tool, including binary discovery and execution logic.
  • src/harness/adapters/opencode.ts
    • Added new file implementing the OpenCodeAdapter for integrating the OpenCode analysis tool, including binary discovery, argument size validation, and execution logic.
  • src/harness/index.ts
    • Added new file establishing the harness registry, functions for registering and retrieving adapters, and logic for resolving the harness fallback chain based on configuration.
    • Implemented runAnalysisWithHarness to execute analysis using the resolved harness chain with fallback mechanisms.
  • src/harness/parsers.ts
    • Added new file containing shared utilities for parsing NDJSON output and extracting text from various harness-specific output formats.
  • src/harness/types.ts
    • Added new file defining TypeScript types for HarnessId, HarnessRunOptions, HarnessRunResult, and the HarnessAdapter interface.
  • src/index.ts
    • Updated imports to use new harness system components (extractAnalysisFromResponseText, resolveHarnessChain, runAnalysisWithHarness, HarnessId).
    • Replaced direct calls to resolveAnalysisProvider and runOpenClawAnalysis with the new resolveHarnessChain and runAnalysisWithHarness functions.
    • Modified the config command to include the new --analysis-harness option and updated configuration display.
    • Refactored analysis execution logic to use the new harness chain, including telemetry recording for harness invocations.
    • Updated rankInsightsForDisplay to accept a generic provider string instead of a limited union type.
  • src/insight-ranker.test.ts
    • Updated imports to include recordRankingTelemetry.
    • Added new test cases for consistency bias mode, exploration guarantee, and accumulation of multiple feedback signals.
    • Added new test suite for recordRankingTelemetry to verify correct event emission for ranked, selected, and suppressed insights.
  • src/insight-ranker.ts
    • Imported recordTelemetryEvent and TelemetrySource.
    • Refined rankInsights logic, including adjustments to how fallback candidates are handled and how selectedKeys are tracked.
    • Added a comment to normalizeActions to cap actions at 4 for UI conciseness and to prevent prompt-stuffing.
    • Implemented recordRankingTelemetry function to standardize telemetry event recording for insight ranking results.
  • src/scripts/daemon.ts
    • Updated imports to use new harness system components (extractAnalysisFromResponseText, resolveHarnessChain, runAnalysisWithHarness).
    • Removed direct CLAUDE_CLI import and currentSessionPids tracking, as process management is now handled by harness adapters.
    • Integrated runAnalysisWithHarness into the daemon's analysis loop, replacing previous provider-specific logic.
    • Enhanced session management to handle context overflow and session errors more robustly with retries and fresh sessions.
    • Updated telemetry recording for insight ranking to use the new recordRankingTelemetry function, skipping insight_selected_for_surface events for daemon runs.
  • src/synapse-db.ts
    • Optimized getInsightQualityMetrics by using a single SQL query to fetch telemetry events and a single-pass classification to populate ranked, selected, and suppressed event arrays, improving performance.
Activity
  • The pull request author, serrrfirat, introduced a comprehensive refactoring of the analysis provider system.
  • Self-review was conducted, leading to robustness hardening measures such as error handlers and argument size validations.
  • A thorough test plan was outlined, including tsc --noEmit and bun test for 93 tests (38 new), all passing successfully.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The pull request introduces a well-structured harness adapter system for analysis providers, which significantly improves code maintainability by consolidating provider-specific logic into a pluggable architecture. The implementation includes support for six different providers with ordered fallback chains and maintains backward compatibility for legacy configuration flags. The addition of comprehensive tests and the optimization of telemetry processing in synapse-db.ts are also noteworthy improvements that enhance the robustness of the system.

Comment thread src/harness/parsers.ts
Comment on lines +82 to +88
else if (parsed.type === 'assistant' && parsed.message?.content) {
for (const block of parsed.message.content) {
if (block.type === 'text' && block.text) {
fullText = block.text;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the assistant message parser, the loop over content blocks overwrites fullText in each iteration. If an assistant message contains multiple text blocks, only the last one will be preserved. The blocks should be accumulated instead, and then assigned to fullText to maintain the intended behavior of using the assistant message as a complete backup.

    else if (parsed.type === 'assistant' && parsed.message?.content) {
      let assistantText = '';
      for (const block of parsed.message.content) {
        if (block.type === 'text' && block.text) {
          assistantText += block.text;
        }
      }
      if (assistantText) fullText = assistantText;
    }

Comment thread src/synapse-db.ts
Comment on lines +1747 to +1763
for (const r of insightEventRows) {
const event: ParsedEvent = {
eventType: r.eventType,
metadata: r.metadata ? (JSON.parse(r.metadata) as Record<string, unknown>) : {},
};
switch (event.eventType) {
case 'insight_ranked':
ranked.push(event);
break;
case 'insight_selected_for_surface':
selected.push(event);
break;
case 'insight_suppressed':
suppressed.push(event);
break;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The JSON.parse call on r.metadata is not wrapped in a try-catch block. If the database contains a row with corrupted or invalid JSON metadata, this loop will throw an exception and cause the entire getInsightQualityMetrics function to fail. Adding error handling here would make the telemetry reporting more robust against data corruption.

  for (const r of insightEventRows) {
    try {
      const event: ParsedEvent = {
        eventType: r.eventType,
        metadata: r.metadata ? (JSON.parse(r.metadata) as Record<string, unknown>) : {},
      };
      switch (event.eventType) {
        case 'insight_ranked':
          ranked.push(event);
          break;
        case 'insight_selected_for_surface':
          selected.push(event);
          break;
        case 'insight_suppressed':
          suppressed.push(event);
          break;
      }
    } catch (e) {
      // Skip events with corrupted metadata
      continue;
    }
  }

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/insight-ranker.ts (1)

217-233: ⚠️ Potential issue | 🟠 Major

keep diverges from final selection after exploration/fallback.
recordRankingTelemetry (and any consumer of ranked.keep) will misreport if exploration swaps in a novel candidate or fallback selects below-threshold items. After selectWithExplorationGuarantee, keep should reflect the final selection.

✅ Fix by re-syncing keep with the final selection
   const selectedSet = new Set(selected.map(entry => entry.rank));
+  ranked.forEach(entry => {
+    entry.keep = selectedSet.has(entry.rank);
+  });
   const suppressed = ranked.filter(entry => !selectedSet.has(entry.rank));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/insight-ranker.ts` around lines 217 - 233, After calling
selectWithExplorationGuarantee (and after the fallback branch) the ranked[].keep
flags may no longer match the final selected array; update the original ranked
entries so their keep property reflects the final selection by building the
selectedSet (from selected.map(e => e.rank)) and iterating ranked to set
entry.keep = selectedSet.has(entry.rank) (and adjust any related
suppressionReasons only if you changed keep for a fallback entry), ensuring
recordRankingTelemetry and other consumers read the correct final keep values.
🧹 Nitpick comments (3)
src/harness/adapters/opencode.ts (1)

62-116: Consider capturing stderr for better diagnostics.

Similar to the Amp adapter, stderr isn't captured. If OpenCode outputs diagnostic information to stderr on failure, it won't be included in error messages.

Also, since the prompt is passed via CLI args rather than stdin, the stdio: ['pipe', ...] for stdin is unused. This is harmless but could be simplified to 'ignore'.

♻️ Optional: Simplify stdin and capture stderr
       const child = spawn(binary, args, {
         cwd: options.cwd || process.cwd(),
-        stdio: ['pipe', 'pipe', 'pipe'],
+        stdio: ['ignore', 'pipe', 'pipe'],
       });

       const timeoutId = setTimeout(() => {
@@ ... @@
       const outputChunks: Buffer[] = [];
+      const stderrChunks: Buffer[] = [];

       child.stdout.on('data', (data) => {
         outputChunks.push(Buffer.from(data));
       });

+      child.stderr.on('data', (data) => {
+        stderrChunks.push(Buffer.from(data));
+      });

       child.on('close', (code) => {
         if (resolved) return;
         resolved = true;
         clearTimeout(timeoutId);
         const output = Buffer.concat(outputChunks).toString('utf-8');
         const durationMs = Date.now() - startMs;

         if (code !== 0) {
+          const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
           resolve({
             ok: false,
-            error: `OpenCode exited with code ${code}`,
+            error: `OpenCode exited with code ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`,
             durationMs,
           });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/harness/adapters/opencode.ts` around lines 62 - 116, The spawn call in
the OpenCode adapter currently ignores stderr and leaves stdin as 'pipe'; update
the child process handling in the Promise returned by the function that spawns
OpenCode so stderr is captured and included in error diagnostics (add a
stderrChunks Buffer array, listen on child.stderr 'data' and push into it, and
include the concatenated stderr text in error responses like the
child.on('error') and non-zero-exit resolve payloads), and simplify stdio to not
keep stdin as a pipe (use 'ignore' for stdin) when calling spawn; refer to the
existing variables/handlers (child, outputChunks, timeoutId, startMs,
extractTextFromOpenCodeOutput) to locate where to add stderr aggregation and
include it in error messages.
src/harness/adapters/amp.ts (1)

68-109: Consider capturing stderr for more informative error messages.

The adapter captures stdout but doesn't collect stderr. If Amp outputs diagnostic information or error details to stderr, they won't be included in the error message when the process exits with a non-zero code.

♻️ Proposed enhancement to capture stderr
       const outputChunks: Buffer[] = [];
+      const stderrChunks: Buffer[] = [];

       child.on('error', (err) => {
@@ ... @@
       child.stdout.on('data', (data) => {
         outputChunks.push(Buffer.from(data));
       });

+      child.stderr.on('data', (data) => {
+        stderrChunks.push(Buffer.from(data));
+      });

       child.on('close', (code) => {
         if (resolved) return;
         resolved = true;
         clearTimeout(timeoutId);
         const output = Buffer.concat(outputChunks).toString('utf-8');
         const durationMs = Date.now() - startMs;

         if (code !== 0) {
+          const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
           resolve({
             ok: false,
-            error: `Amp exited with code ${code}`,
+            error: `Amp exited with code ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`,
             durationMs,
           });
           return;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/harness/adapters/amp.ts` around lines 68 - 109, The adapter currently
only captures stdout (outputChunks) and ignores stderr, so when the spawned
process (child) fails you should also collect stderr and include it in error
responses; add a stderrChunks: Buffer[] (or similar) and push Buffer.from(data)
on child.stderr 'data', then when resolving on non-zero exit (in the
child.on('close') handler) include the collected stderr text (joined/trimmed) in
the returned error message or metadata (e.g., append to the error field or set
metadata.stderr) and also include stderr where child.on('error') resolves;
reference the existing symbols child, outputChunks, extractTextFromAmpOutput,
and the child.on('close')/child.on('error') handlers to locate where to add
stderr collection and include it in error responses.
src/index.ts (1)

768-786: Consider using a stricter type cast.

The validation ensures only valid harness IDs are saved, but the as any[] cast on line 783 loses type safety. Since VALID_HARNESS_IDS already enforces valid values, casting to HarnessId[] would be more precise.

♻️ Suggested improvement
-      saveConfig({ analysisHarness: rawIds as any[] });
+      saveConfig({ analysisHarness: rawIds as HarnessId[] });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 768 - 786, The cast to any[] when saving validated
harness IDs weakens type safety; replace the broad cast with the correct
HarnessId[] type. In the block where options.analysisHarness is handled
(symbols: VALID_HARNESS_IDS, rawIds, saveConfig), assert or convert rawIds to
HarnessId[] (e.g., via a typed map or an explicit cast to HarnessId[] after
validation) and pass that to saveConfig so only HarnessId[] is saved instead of
any[].
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/harness/adapters/codex.ts`:
- Around line 52-66: The code writes to child.stdin without checking it, which
can crash if spawn fails and child.stdin is null; change the logic in
src/harness/adapters/codex.ts (and mirror in claude-code.ts and amp.ts) to
attach the child.stdin 'error' handler before any writes and guard child.stdin
!== null before calling child.stdin.write(options.prompt) and child.stdin.end();
if stdin is null, handle the spawn failure path (clear timeout, kill child if
needed and resolve/reject the promise consistently) so the process falls back
instead of throwing a null-deref.

In `@src/harness/index.ts`:
- Around line 109-143: The loop currently calls adapter.run(...) without
guarding against exceptions, so a thrown error aborts the fallback chain; wrap
the adapter.run invocation in a try/catch (around the await adapter.run call in
the harnessChain loop), and on catch convert the exception into a failure: push
a descriptive entry like `${harnessId}: ${err?.message || String(err)}` onto the
errors array (or set result = { ok: false, error: ... }) and continue the loop
so the next harness is attempted; preserve the existing behavior that only when
result.rawText exists you return immediately, but ensure thrown exceptions do
not short-circuit the chain.

---

Outside diff comments:
In `@src/insight-ranker.ts`:
- Around line 217-233: After calling selectWithExplorationGuarantee (and after
the fallback branch) the ranked[].keep flags may no longer match the final
selected array; update the original ranked entries so their keep property
reflects the final selection by building the selectedSet (from selected.map(e =>
e.rank)) and iterating ranked to set entry.keep = selectedSet.has(entry.rank)
(and adjust any related suppressionReasons only if you changed keep for a
fallback entry), ensuring recordRankingTelemetry and other consumers read the
correct final keep values.

---

Nitpick comments:
In `@src/harness/adapters/amp.ts`:
- Around line 68-109: The adapter currently only captures stdout (outputChunks)
and ignores stderr, so when the spawned process (child) fails you should also
collect stderr and include it in error responses; add a stderrChunks: Buffer[]
(or similar) and push Buffer.from(data) on child.stderr 'data', then when
resolving on non-zero exit (in the child.on('close') handler) include the
collected stderr text (joined/trimmed) in the returned error message or metadata
(e.g., append to the error field or set metadata.stderr) and also include stderr
where child.on('error') resolves; reference the existing symbols child,
outputChunks, extractTextFromAmpOutput, and the
child.on('close')/child.on('error') handlers to locate where to add stderr
collection and include it in error responses.

In `@src/harness/adapters/opencode.ts`:
- Around line 62-116: The spawn call in the OpenCode adapter currently ignores
stderr and leaves stdin as 'pipe'; update the child process handling in the
Promise returned by the function that spawns OpenCode so stderr is captured and
included in error diagnostics (add a stderrChunks Buffer array, listen on
child.stderr 'data' and push into it, and include the concatenated stderr text
in error responses like the child.on('error') and non-zero-exit resolve
payloads), and simplify stdio to not keep stdin as a pipe (use 'ignore' for
stdin) when calling spawn; refer to the existing variables/handlers (child,
outputChunks, timeoutId, startMs, extractTextFromOpenCodeOutput) to locate where
to add stderr aggregation and include it in error messages.

In `@src/index.ts`:
- Around line 768-786: The cast to any[] when saving validated harness IDs
weakens type safety; replace the broad cast with the correct HarnessId[] type.
In the block where options.analysisHarness is handled (symbols:
VALID_HARNESS_IDS, rawIds, saveConfig), assert or convert rawIds to HarnessId[]
(e.g., via a typed map or an explicit cast to HarnessId[] after validation) and
pass that to saveConfig so only HarnessId[] is saved instead of any[].

Comment on lines +52 to +66
return new Promise((resolve) => {
const child = spawn(binary, args, {
cwd: options.cwd || process.cwd(),
stdio: ['pipe', 'pipe', 'pipe'],
});

const timeoutId = setTimeout(() => {
try { child.kill('SIGTERM'); } catch { /* already dead */ }
}, timeoutMs);

let resolved = false;

child.stdin.on('error', () => {}); // ignore EPIPE if child exits early
child.stdin.write(options.prompt);
child.stdin.end();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/harness/adapters/codex.ts | head -100

Repository: serrrfirat/synapse-daemon

Length of output: 3555


🏁 Script executed:

rg -n "child\.stdin" src/harness/adapters -g '*.ts' -C 2

Repository: serrrfirat/synapse-daemon

Length of output: 1420


🌐 Web query:

Node.js spawn child.stdin null on spawn failure

💡 Result:

In Node.js, child_process.spawn() always returns a ChildProcess object, but child.stdin (and .stdout/.stderr) can be null or undefined in two common cases:

  1. You didn’t create a pipe for fd 0 (stdin)
    If you spawn with stdio: 'inherit', stdio: 'ignore', or otherwise set stdio[0] to something other than 'pipe', then child.stdin will be null. Node documents: “If the child process was spawned with stdio[0] set to anything other than 'pipe', then this will be null.” (nodejs.org)

  2. The process failed to spawn (e.g., ENOENT)
    If the command can’t be started (missing executable, bad cwd, permissions, etc.), Node emits an 'error' event, and the .stdin/.stdout/.stderr properties may be null/undefined because there is no child process / pipes to attach to. Node documents: “The subprocess.stdin property can be null or undefined if the child process could not be successfully spawned.” (nodejs.org)

What to do

  • Always attach an error handler and treat it as “spawn failed; streams may not exist”.
  • Only write to child.stdin if it exists (or ensure stdio[0] is 'pipe').
import { spawn } from "node:child_process";

const child = spawn("some-command", ["arg"], { stdio: ["pipe", "pipe", "pipe"] });

child.on("error", (err) => {
  // spawn failed (e.g. ENOENT). stdin/stdout/stderr may be null/undefined.
  console.error("spawn failed:", err.code, err.message);
});

if (child.stdin) {
  child.stdin.write("input\n");
  child.stdin.end();
}

If you share stdin with the parent (e.g. stdio: 'inherit'), child.stdin being null is expected—there is no writable pipe exposed to you. (nodejs.org)

Citations:


Guard child.stdin before writing to avoid null-deref on spawn failure.

If the binary is not found or spawn fails (e.g., ENOENT, permissions), Node.js can leave child.stdin as null even with stdio: ['pipe', 'pipe', 'pipe']. The current code attempts to write to it at lines 64–66 before the error handler is attached, causing a crash before fallback handling runs.

The same vulnerability exists in src/harness/adapters/claude-code.ts (lines 57–59) and src/harness/adapters/amp.ts (lines 64–66).

🛠️ Suggested fix
       let resolved = false;

+      if (!child.stdin) {
+        resolved = true;
+        clearTimeout(timeoutId);
+        resolve({
+          ok: false,
+          error: 'Failed to spawn: stdin unavailable',
+          durationMs: Date.now() - startMs,
+        });
+        return;
+      }
+
       child.stdin.on('error', () => {}); // ignore EPIPE if child exits early
       child.stdin.write(options.prompt);
       child.stdin.end();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/harness/adapters/codex.ts` around lines 52 - 66, The code writes to
child.stdin without checking it, which can crash if spawn fails and child.stdin
is null; change the logic in src/harness/adapters/codex.ts (and mirror in
claude-code.ts and amp.ts) to attach the child.stdin 'error' handler before any
writes and guard child.stdin !== null before calling
child.stdin.write(options.prompt) and child.stdin.end(); if stdin is null,
handle the spawn failure path (clear timeout, kill child if needed and
resolve/reject the promise consistently) so the process falls back instead of
throwing a null-deref.

Comment thread src/harness/index.ts

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: src/harness/adapters/cursor.ts

Bug: stderr pipe not consumed — potential deadlock

Severity: High

The adapter opens stderr as a pipe (stdio: ['pipe', 'pipe', 'pipe']) but never reads from it. If cursor-agent writes more than the OS pipe buffer size (~64KB on macOS) to stderr, it will block on the write and deadlock the process — it'll hang forever since the close event never fires.

The claude-code adapter handles this correctly by consuming stderrChunks. The fix here is either:

  • Capture stderr (preferred — useful for error diagnostics):
    const stderrChunks: Buffer[] = [];
    child.stderr.on('data', (data) => { stderrChunks.push(Buffer.from(data)); });
    Then include stderr in the error message on non-zero exit.
  • Or set stderr to 'ignore' or 'inherit' in the spawn options if you don't need it.

This same issue exists in opencode.ts, amp.ts, and codex.ts.


Bug: stdin pipe opened but never closed

Severity: Medium

The spawn options open stdin as 'pipe', but the cursor adapter never writes to it or calls child.stdin.end(). The child process gets a dangling open stdin pipe. If cursor-agent ever reads from stdin (even accidentally), it will hang waiting for input.

Compare with amp.ts/codex.ts/claude-code.ts which all properly do:

child.stdin.write(options.prompt);
child.stdin.end();

Since cursor passes the prompt via CLI args, it should either:

  • Call child.stdin.end() immediately to signal EOF, or
  • Set stdin to 'ignore' in the spawn options: stdio: ['ignore', 'pipe', 'pipe']

Same issue in opencode.ts.


Missing: No EPIPE guard on stdin

Severity: Low (since stdin isn't written to, but inconsistent with other adapters)

The PR description mentions "EPIPE guards on stdin-piping adapters," and amp.ts, codex.ts, and claude-code.ts all have:

child.stdin.on('error', () => {}); // ignore EPIPE if child exits early

cursor.ts (and opencode.ts) lack this. Not critical since they don't write to stdin, but if you fix the dangling-stdin issue above by closing it, you'd want the guard.


Nit: Prompt visible in process listing

Severity: Informational

The prompt is passed as a CLI argument (-p, options.prompt), making it visible to all users via ps aux. If the prompt contains sensitive codebase context, this is a minor privacy leak. The MAX_ARG_BYTES guard is good for preventing execve failures, but stdin piping (like claude-code/codex/amp do) would be more secure. This may just be a cursor-agent CLI limitation.


Nit: No stderr in error messages

On non-zero exit, the error only reports the exit code:

error: `Cursor Agent exited with code ${code}`,

The claude-code adapter includes stderr in the error message (stderr.slice(0, 300)), which is much more useful for debugging. Even if you don't capture stderr as a buffer, at minimum inheriting it would let the user see what went wrong.


Nit: sessionId silently ignored

The HarnessRunOptions interface has a sessionId field. The cursor adapter silently ignores it. This is probably fine if cursor-agent doesn't support sessions, but a comment noting this limitation would help maintainability.


Design note: heavy duplication across adapters

cursor.ts, opencode.ts, amp.ts, and codex.ts are ~95% identical — same findBinary() pattern, same isAvailable(), same run() structure with only the binary name, CLI args, and parser differing. Not a blocker for this PR, but a base class or factory function could eliminate ~300 lines of near-duplicate code in a follow-up.


Summary

Issue Severity
stderr pipe not consumed (deadlock risk) High
stdin pipe opened but never closed Medium
Missing EPIPE guard (inconsistent) Low
No stderr in error messages Low
Prompt visible in process list Informational
sessionId silently ignored Informational
Heavy adapter duplication Design debt

The stderr deadlock is the main actionable issue — it can cause the adapter to hang indefinitely in production if cursor-agent is chatty on stderr.

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: REQUEST CHANGES

Blocking Issues

  1. HIGH: Daemon lost stale session error detection specificity — Old code checked for specific markers ('no conversation found with session id', 'session not found'). New code does harnessResult.error?.includes('session') — far too broad, matching any error containing "session", causing false-positive session resets and wasting tokens.

  2. HIGH: Daemon lost build proposal detection logic — Old code had extensive build proposal creation, skill gap detection, and semantic gap detection. New code calls processStructuredAnalysis(extracted.analysis). Verify that processStructuredAnalysis includes ALL of this logic, or this is a feature regression.

  3. MEDIUM: Null exit code treated as success — Claude Code adapter coalesces code ?? 0, so a SIGTERM-killed process (null exit code) is treated as successful with partial output.

  4. MEDIUM: Timeout handler does not force-resolve Promise — If child.kill('SIGTERM') fails and process never exits, the Promise hangs forever. Add a fallback resolve after a grace period.

Non-Blocking Issues

  • OpenClaw adapter missing contextOverflow detection
  • saveConfig({ analysisHarness: rawIds as any[] }) — unsafe cast, should be HarnessId[]
  • SIGTERM without SIGKILL escalation across all adapters
  • isAvailable() only checks file existence, not executability
  • PATH splitting uses : — breaks on Windows (use path.delimiter)
  • Missing stderr collection in non-Claude adapters
  • similarity() denominator change (same as other PRs)

…type safety

- Consume stderr in all adapters (cursor, opencode, amp, codex) to prevent
  deadlock when child process writes >64KB to stderr; include stderr in error
  messages on non-zero exit
- Change stdin to 'ignore' for cursor/opencode adapters since they pass
  prompts via CLI args and never write to stdin
- Treat null exit code as failure in claude-code adapter (SIGTERM-killed
  process should not be treated as success)
- Make stale session detection in daemon more specific: match known error
  strings instead of broad 'session' substring
- Re-sync ranked[].keep flags after selectWithExplorationGuarantee to keep
  the ranked array consistent with the final selected set
- Wrap adapter.run() in try/catch in harness chain loop so a thrown exception
  does not abort the fallback chain
- Replace `as any[]` with `as HarnessId[]` for type safety
- Use true Jaccard similarity (union denominator) in insight ranker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/scripts/daemon.ts (1)

1368-1394: ⚠️ Potential issue | 🟡 Minor

Gate the Claude-specific recovery hint by harness capability.

The recovery prompt hard-codes claude-mem and a search MCP tool. On non-Claude harnesses this can produce unsupported tool calls or degrade responses. Consider only injecting it when the primary harness supports these tools (or make the hint provider-agnostic).

Suggested fix
-  if (isRecoverySession) {
+  const supportsClaudeMem = harnessChain[0] === 'claude_code';
+  if (isRecoverySession && supportsClaudeMem) {
     const recoveryHint = `
 **IMPORTANT: Session Recovery Mode**
 This is a fresh session after context overflow. You have access to claude-mem which has observations from previous Synapse analysis sessions. Before analyzing, use the \`search\` MCP tool to query "synapse analysis patterns insights" to retrieve relevant context from prior runs. This will help you maintain continuity and avoid re-suggesting patterns that were already handled.
 
 `;
     finalPrompt = recoveryHint + prompt;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scripts/daemon.ts` around lines 1368 - 1394, The recoveryHint currently
hardcodes claude-mem and the search MCP tool and should only be injected when
the selected harness actually supports those capabilities; update the logic
around resolveHarnessChain/harnessChain and isRecoverySession so that before
setting finalPrompt you detect whether the primary harness (from harnessChain or
an available harness capability object) supports claude-mem and the search MCP,
and only prepend recoveryHint when those capabilities are present—otherwise
inject a provider-agnostic hint or skip the tool-specific guidance; update
references to resolveHarnessChain, harnessChain, isRecoverySession, finalPrompt,
and recoveryHint accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/harness/adapters/cursor.ts`:
- Around line 69-114: The spawn result `child` may have null
`child.stdout`/`child.stderr` on spawn failure which causes TypeError when
attaching listeners; update the listener setup in the Cursor spawn block to
guard access (e.g., check `if (child.stdout) { child.stdout.on('data', ...) }`
and similarly for `child.stderr`) and ensure you still collect empty buffers
when streams are absent so the existing `close`/`error`/timeout handling
(variables `resolved`, `timeoutId`, `outputChunks`, `stderrChunks`) works
unchanged; keep the rest of the `child.on('error')` and `child.on('close')`
logic intact.

In `@src/harness/adapters/opencode.ts`:
- Around line 62-110: The child process streams (child.stdout / child.stderr)
must be null-checked before attaching listeners to avoid synchronous throws when
spawn fails; update the spawn handling in the Promise around the "child"
returned by spawn so that you only call child.stdout.on(...) and
child.stderr.on(...) if child.stdout/child.stderr are truthy, and if either
stream is missing immediately clearTimeout(timeoutId), mark resolved = true and
resolve with ok: false and an informative error (e.g., "Missing stdio streams
from spawned process") including durationMs; keep the existing child.on('error')
and child.on('close') behavior otherwise so failure paths remain consistent.

In `@src/scripts/daemon.ts`:
- Around line 1490-1541: The run currently treats failed structured extraction
as a successful run: after calling extractAnalysisFromResponseText it still
updates state.runCount/lastUsedAt, calls saveSessionState, recordAnalysisRun,
updates streaks and may send notifications; instead, change the control flow so
that when extracted.analysis is falsy you do not increment state.runCount or
update lastUsedAt and you do not call recordAnalysisRun,
getConsecutiveDaysStreak or sendNotification. Keep the existing retry/reset
logic (sessionId reset, saveSessionState when forcing a fresh session) and
always ensure synapseDb.close() runs, but move the state updates,
saveSessionState, recordAnalysisRun, streak check (getConsecutiveDaysStreak) and
reflection/notification logic into the branch where extracted.analysis exists
(after await processStructuredAnalysis) so only successful extractions are
recorded.

---

Outside diff comments:
In `@src/scripts/daemon.ts`:
- Around line 1368-1394: The recoveryHint currently hardcodes claude-mem and the
search MCP tool and should only be injected when the selected harness actually
supports those capabilities; update the logic around
resolveHarnessChain/harnessChain and isRecoverySession so that before setting
finalPrompt you detect whether the primary harness (from harnessChain or an
available harness capability object) supports claude-mem and the search MCP, and
only prepend recoveryHint when those capabilities are present—otherwise inject a
provider-agnostic hint or skip the tool-specific guidance; update references to
resolveHarnessChain, harnessChain, isRecoverySession, finalPrompt, and
recoveryHint accordingly.

---

Duplicate comments:
In `@src/harness/adapters/amp.ts`:
- Around line 62-88: The code assumes child.stdin/stdout/stderr are non-null and
writes/listens unguarded, which can crash on spawn failure; update the block
around child (the writes and 'error' handler attachments) to first null-check
each stdio before using it: only call child.stdin.on('error'),
child.stdin.write(options.prompt) and child.stdin.end() if child.stdin exists
(guard EPIPE handling), and only attach child.stdout.on('data', ...) and
child.stderr.on('data', ...) if child.stdout/child.stderr are non-null; keep the
existing child.on('error') logic but ensure it still clears timeout and resolves
when invoked even if stdio were skipped.

In `@src/harness/adapters/claude-code.ts`:
- Around line 42-63: Spawn can fail and leave stdio handles null, so guard uses
of child.stdin/stdout/stderr to avoid crashes: before calling
child.stdin.on('error', ...), child.stdin.write(options.prompt), or
child.stdin.end(), check if child.stdin is non-null; likewise when attaching
data listeners or collecting outputChunks/stderrChunks, check child.stdout and
child.stderr exist first. Update the code around spawn and the variables
outputChunks/stderrChunks to conditionally attach listeners and write only when
the corresponding handle is present (use simple if (child.stdin) { ... } and if
(child.stdout) { ... } etc.), and ensure any timeout/cleanup still runs if stdio
is missing so the Promise resolves/rejects appropriately.

In `@src/harness/adapters/codex.ts`:
- Around line 62-88: The code currently assumes child.stdio streams exist and
writes/listens unguarded, which can throw if spawn fails; update the block
around child to first check for and guard child.stdin before calling
child.stdin.on('error'), child.stdin.write(options.prompt) and
child.stdin.end(), and only attach 'data' handlers to child.stdout and
child.stderr if those properties are non-null/defined; keep the existing
child.on('error') logic (using resolved, timeoutId, startMs) unchanged but
ensure the guarded checks prevent crashes when spawn returns null/undefined
stdio.

Comment on lines +69 to +114
const child = spawn(binary, args, {
cwd: options.cwd || process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
});

const timeoutId = setTimeout(() => {
try { child.kill('SIGTERM'); } catch { /* already dead */ }
}, timeoutMs);

child.on('error', (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
resolve({
ok: false,
error: `Failed to spawn Cursor Agent: ${err.message}`,
durationMs: Date.now() - startMs,
});
});

const outputChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];

child.stdout.on('data', (data) => {
outputChunks.push(Buffer.from(data));
});

child.stderr.on('data', (data) => {
stderrChunks.push(Buffer.from(data));
});

child.on('close', (code) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
const output = Buffer.concat(outputChunks).toString('utf-8');
const durationMs = Date.now() - startMs;

if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
resolve({
ok: false,
error: `Cursor Agent exited with code ${code}${stderr ? ': ' + stderr.slice(0, 300) : ''}`,
durationMs,
});
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -type f -name "cursor.ts" 2>/dev/null | head -5

Repository: serrrfirat/synapse-daemon

Length of output: 51


🏁 Script executed:

# Search for the cursor.ts file
find . -type f -name "cursor.ts" 2>/dev/null

Repository: serrrfirat/synapse-daemon

Length of output: 101


🏁 Script executed:

# List the structure to understand the repository
git ls-files | grep -E "(cursor|adapter)" | head -20

Repository: serrrfirat/synapse-daemon

Length of output: 259


🏁 Script executed:

wc -l ./src/harness/adapters/cursor.ts

Repository: serrrfirat/synapse-daemon

Length of output: 105


🏁 Script executed:

cat -n ./src/harness/adapters/cursor.ts | sed -n '60,120p'

Repository: serrrfirat/synapse-daemon

Length of output: 2274


🌐 Web query:

Node.js spawn stdio pipe stdout stderr null when can be null

💡 Result:

In Node.js, child.stdout / child.stderr from child_process.spawn() are only non-null when the corresponding stdio entry is 'pipe'.

They can be:

  • Readable stream: when stdio[1] === 'pipe' (for stdout) and/or stdio[2] === 'pipe' (for stderr) (this is the default if you don’t override stdio). [1]
  • null: when you spawn with that fd set to anything other than 'pipe'—e.g. 'inherit', 'ignore', a file descriptor number, or a stream. [1]
  • undefined: if the child process could not be successfully spawned (spawn failure). [1]

So if you set stdio: 'inherit' (or otherwise don’t pipe fd 1/2), child.stdout / child.stderr will be null. [1]


🏁 Script executed:

# Check similar patterns in other adapter files
grep -A 10 "spawn(" ./src/harness/adapters/*.ts | grep -E "(stdout|stderr)" | head -20

Repository: serrrfirat/synapse-daemon

Length of output: 51


🌐 Web query:

Node.js spawn error event timing child process stdio pipe stream initialization

💡 Result:

In child_process.spawn(), the ChildProcess object is returned immediately, but a failed spawn is reported asynchronously via the child’s 'error' event (e.g., ENOENT when the command or cwd doesn’t exist). (nodejs.org)

Event ordering (reliable rules)

  • Success path: 'spawn' is emitted first, before any stdout/stderr data events. (nodejs.org)
  • Failure-to-spawn path: 'spawn' is not emitted; instead 'error' is emitted. (nodejs.org)
  • If the child fails to spawn, 'close' will be emitted after 'error'. (nodejs.org)
  • After an 'error', 'exit' may or may not fire. (nodejs.org)

Stdio pipe stream initialization implications

Even if you request stdio: 'pipe', the stdio handles may not be usable when spawn fails:

  • subprocess.stdio can be undefined if the child could not be spawned. (nodejs.org)
  • subprocess.stdout / stdin / stderr can be null or undefined if the child could not be spawned. (nodejs.org)

So, don’t assume child.stdout exists right after spawn()—guard it (or attach stream listeners after you know spawn succeeded, e.g., in 'spawn', or check if (child.stdout) ...). (nodejs.org)

Citations:


🏁 Script executed:

# Check if there are other error handling patterns in the codebase
grep -r "child.stdout" ./src --include="*.ts" -B 2 -A 2

Repository: serrrfirat/synapse-daemon

Length of output: 2251


Guard stdout/stderr before attaching listeners.

On spawn failure, child.stdout and child.stderr can be null; attempting to attach listeners on null streams will throw a TypeError and leave the promise unresolved.

🛠️ Proposed fix
       const timeoutId = setTimeout(() => {
         try { child.kill('SIGTERM'); } catch { /* already dead */ }
       }, timeoutMs);

+      if (!child.stdout || !child.stderr) {
+        resolved = true;
+        clearTimeout(timeoutId);
+        resolve({
+          ok: false,
+          error: 'Failed to spawn Cursor Agent: stdio unavailable',
+          durationMs: Date.now() - startMs,
+        });
+        return;
+      }
+
       child.on('error', (err) => {
         if (resolved) return;
         resolved = true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/harness/adapters/cursor.ts` around lines 69 - 114, The spawn result
`child` may have null `child.stdout`/`child.stderr` on spawn failure which
causes TypeError when attaching listeners; update the listener setup in the
Cursor spawn block to guard access (e.g., check `if (child.stdout) {
child.stdout.on('data', ...) }` and similarly for `child.stderr`) and ensure you
still collect empty buffers when streams are absent so the existing
`close`/`error`/timeout handling (variables `resolved`, `timeoutId`,
`outputChunks`, `stderrChunks`) works unchanged; keep the rest of the
`child.on('error')` and `child.on('close')` logic intact.

Comment on lines +62 to +110
return new Promise((resolve) => {
let resolved = false;

const child = spawn(binary, args, {
cwd: options.cwd || process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
});

const timeoutId = setTimeout(() => {
try { child.kill('SIGTERM'); } catch { /* already dead */ }
}, timeoutMs);

child.on('error', (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
resolve({
ok: false,
error: `Failed to spawn OpenCode: ${err.message}`,
durationMs: Date.now() - startMs,
});
});

const outputChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];

child.stdout.on('data', (data) => {
outputChunks.push(Buffer.from(data));
});

child.stderr.on('data', (data) => {
stderrChunks.push(Buffer.from(data));
});

child.on('close', (code) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
const output = Buffer.concat(outputChunks).toString('utf-8');
const durationMs = Date.now() - startMs;

if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
resolve({
ok: false,
error: `OpenCode exited with code ${code}${stderr ? ': ' + stderr.slice(0, 300) : ''}`,
durationMs,
});
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/harness/adapters/opencode.ts | head -150

Repository: serrrfirat/synapse-daemon

Length of output: 4607


🌐 Web query:

Node.js child_process spawn stdio pipe stdout stderr null conditions

💡 Result:

In child_process.spawn(cmd, args, { stdio }), the stdio option controls what the child’s fds (0=stdin, 1=stdout, 2=stderr, …) are connected to.

When are child.stdout / child.stderr a stream vs null?

  • child.stdout is a Readable stream only if stdio[1] === 'pipe'. Otherwise it is null. [1]
  • child.stderr is a Readable stream only if stdio[2] === 'pipe'. Otherwise it is null. [1]
  • Both are aliases of child.stdio[1] / child.stdio[2]. [1]

What does 'pipe' mean?

  • 'pipe' creates a pipe between parent and child, exposing the parent end as child.stdio[fd] (and for fds 0–2 also as child.stdin/stdout/stderr). [2]

What do null / undefined mean in stdio?

  • null or undefined means “use the default”:
    • For fds 0, 1, 2: default is create a pipe.
    • For fd 3 and up: default is 'ignore'. [1]

Why might you see undefined?

  • If the child process could not be successfully spawned, subprocess.stdio (and therefore .stdout / .stderr) can be undefined. [1]

Example

  • spawn('prg', [], { stdio: ['pipe', null, null] }) → stdout/stderr default to pipes, so child.stdout and child.stderr are streams. [1]

Sources: Node.js child_process docs (v22.17.1). [1] and background semantics for 'pipe'. [2]

[1] (nodejs.org)
[2] (nodejs.org)

Citations:


🌐 Web query:

Node.js spawn stdio undefined when spawn fails error event timing

💡 Result:

When child_process.spawn() fails before the OS process is actually created (e.g., ENOENT, EACCES), Node still returns a ChildProcess object, but its stdio streams may not exist yet—subprocess.stdio can be undefined, and subprocess.stdin/stdout/stderr can be undefined (or null depending on stdio config). This is explicitly documented. [1]

Event timing/ordering in this failure case:

  • No 'spawn' event is emitted if the child didn’t spawn successfully; instead 'error' is emitted. [2]
  • After an 'error', 'exit' may or may not fire, so don’t rely on it for cleanup. [2]
  • 'close' will be emitted after 'exit' (if it happens) or after 'error' if the child failed to spawn. [2]

Practical pattern to avoid “stdio undefined” bugs:

  • Attach an 'error' handler immediately after calling spawn().
  • Only touch cp.stdio / cp.stdout / cp.stderr after:
    • you’ve seen the 'spawn' event, or
    • you’ve checked they’re not undefined/null.

Also: if spawning fails, cp.pid is undefined. [2]

Sources:
[1] Node.js docs: subprocess.stdio/stdin/stdout/stderr can be undefined if spawn fails https://nodejs.org/download/release/v22.17.1/docs/api/child_process.html
[2] Node.js docs: 'error', 'spawn', 'exit'/'close' behavior and ordering https://nodejs.org/download/release/v22.19.0/docs/api/child_process.html


Guard child.stdout and child.stderr before attaching listeners.

On spawn failure (e.g., binary not found, permission denied), child.stdout/child.stderr can be undefined. The current code attaches listeners synchronously at lines 88–94 without null checks, which will throw before the async error handler at line 74 fires.

🛠️ Proposed fix
       const timeoutId = setTimeout(() => {
         try { child.kill('SIGTERM'); } catch { /* already dead */ }
       }, timeoutMs);

+      if (!child.stdout || !child.stderr) {
+        resolved = true;
+        clearTimeout(timeoutId);
+        resolve({
+          ok: false,
+          error: 'Failed to spawn OpenCode: stdio unavailable',
+          durationMs: Date.now() - startMs,
+        });
+        return;
+      }
+
       child.on('error', (err) => {
         if (resolved) return;
         resolved = true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return new Promise((resolve) => {
let resolved = false;
const child = spawn(binary, args, {
cwd: options.cwd || process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeoutId = setTimeout(() => {
try { child.kill('SIGTERM'); } catch { /* already dead */ }
}, timeoutMs);
child.on('error', (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
resolve({
ok: false,
error: `Failed to spawn OpenCode: ${err.message}`,
durationMs: Date.now() - startMs,
});
});
const outputChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (data) => {
outputChunks.push(Buffer.from(data));
});
child.stderr.on('data', (data) => {
stderrChunks.push(Buffer.from(data));
});
child.on('close', (code) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
const output = Buffer.concat(outputChunks).toString('utf-8');
const durationMs = Date.now() - startMs;
if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
resolve({
ok: false,
error: `OpenCode exited with code ${code}${stderr ? ': ' + stderr.slice(0, 300) : ''}`,
durationMs,
});
return;
return new Promise((resolve) => {
let resolved = false;
const child = spawn(binary, args, {
cwd: options.cwd || process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeoutId = setTimeout(() => {
try { child.kill('SIGTERM'); } catch { /* already dead */ }
}, timeoutMs);
if (!child.stdout || !child.stderr) {
resolved = true;
clearTimeout(timeoutId);
resolve({
ok: false,
error: 'Failed to spawn OpenCode: stdio unavailable',
durationMs: Date.now() - startMs,
});
return;
}
child.on('error', (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
resolve({
ok: false,
error: `Failed to spawn OpenCode: ${err.message}`,
durationMs: Date.now() - startMs,
});
});
const outputChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (data) => {
outputChunks.push(Buffer.from(data));
});
child.stderr.on('data', (data) => {
stderrChunks.push(Buffer.from(data));
});
child.on('close', (code) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
const output = Buffer.concat(outputChunks).toString('utf-8');
const durationMs = Date.now() - startMs;
if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
resolve({
ok: false,
error: `OpenCode exited with code ${code}${stderr ? ': ' + stderr.slice(0, 300) : ''}`,
durationMs,
});
return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/harness/adapters/opencode.ts` around lines 62 - 110, The child process
streams (child.stdout / child.stderr) must be null-checked before attaching
listeners to avoid synchronous throws when spawn fails; update the spawn
handling in the Promise around the "child" returned by spawn so that you only
call child.stdout.on(...) and child.stderr.on(...) if child.stdout/child.stderr
are truthy, and if either stream is missing immediately clearTimeout(timeoutId),
mark resolved = true and resolve with ok: false and an informative error (e.g.,
"Missing stdio streams from spawned process") including durationMs; keep the
existing child.on('error') and child.on('close') behavior otherwise so failure
paths remain consistent.

Comment thread src/scripts/daemon.ts
Comment on lines +1490 to 1541
// ── Extract structured analysis ───────────────────────────────────
const responseText = harnessResult.rawText || '';
const extracted = extractAnalysisFromResponseText(responseText);

// Detect context overflow errors
const errorStr = String(e).toLowerCase();
const isContextOverflow =
outputLower.includes('prompt is too long') ||
outputLower.includes('context length') ||
outputLower.includes('maximum context') ||
errorStr.includes('unterminated string') || // JSON parse error often from truncation
(code !== 0 && state.runCount >= MAX_SESSION_RUNS);

if (isContextOverflow && state.sessionId && !isRetry) {
// Check if we already retried recently (within 1 hour) to prevent token waste
const lastRetry = state.lastRetryAt ? new Date(state.lastRetryAt).getTime() : 0;
const hourAgo = Date.now() - 60 * 60 * 1000;

if (lastRetry > hourAgo) {
log('CONTEXT OVERFLOW - already retried within the hour, skipping to prevent token waste');
} else {
log('CONTEXT OVERFLOW DETECTED - retrying once with fresh session');
// Clear session to force fresh start
state.sessionId = null;
state.runCount = 0;
state.contextOverflow = false;
state.lastRetryAt = new Date().toISOString();
saveSessionState(state);
synapseDb.close();
// Retry once with fresh session
resolve();
await runAnalysis(hours, mode, true);
return;
}
} else if (isContextOverflow) {
log('CONTEXT OVERFLOW on retry - giving up, will try next cycle');
}
}
if (!extracted.analysis) {
log('Could not parse structured analysis from response');
log(`Response preview: ${responseText.slice(0, 200)}`);

// Update session state
if (newSessionId) {
state.sessionId = newSessionId;
log(`Session ID: ${newSessionId.slice(0, 8)}...`);
}
state.lastUsedAt = new Date().toISOString();
state.runCount++;
state.mode = mode;
// Check if this might be a session/context issue
if (!isRetry && state.runCount >= MAX_SESSION_RUNS) {
log('May be context overflow after many runs - retrying with fresh session');
state.sessionId = null;
state.runCount = 0;
state.lastRetryAt = new Date().toISOString();
saveSessionState(state);

// Record analysis run
const endTime = new Date();
const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000);
const patternsFound = state.runCount; // Use run count as proxy
recordAnalysisRun(synapseDb, startTime, endTime, patternsFound);

// Log consecutive days streak (derived from analysis_runs)
const consecutiveDays = getConsecutiveDaysStreak(synapseDb);
log(`Consecutive days of usage: ${consecutiveDays}`);

// Check if reflection should run (daily trigger)
if (shouldRunReflection(synapseDb)) {
log('Reflection trigger: observations accumulated. Run "synapse reflect" to review skill improvements.');
sendNotification(
'Synapse Reflect',
'Skill observations ready for reflection. Run "synapse reflect" to review.'
);
}

synapseDb.close();
log(`Run #${state.runCount} complete`);
await runAnalysis(hours, mode, true);
return;
}
} else {
// ── Process structured analysis ────────────────────────────────
await processStructuredAnalysis(extracted.analysis);
}

resolve();
};
// ── Update session state ──────────────────────────────────────────
state.lastUsedAt = new Date().toISOString();
state.runCount++;
state.mode = mode;
saveSessionState(state);

// Listen for stdout to fully end
claude.stdout.on('end', () => {
stdoutEnded = true;
processOutput();
});
// Record analysis run
const endTime = new Date();
const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000);
const patternsFound = extracted.analysis?.patterns?.length || 0;
recordAnalysisRun(synapseDb, startTime, endTime, patternsFound);

// Log consecutive days streak
const consecutiveDays = getConsecutiveDaysStreak(synapseDb);
log(`Consecutive days of usage: ${consecutiveDays}`);

// Check if reflection should run
if (shouldRunReflection(synapseDb)) {
log('Reflection trigger: observations accumulated. Run "synapse reflect" to review skill improvements.');
sendNotification(
'Synapse Reflect',
'Skill observations ready for reflection. Run "synapse reflect" to review.'
);
}

// Listen for process to exit
claude.on('close', (code) => {
exitCode = code ?? 0;
// Clean up: clear timeout and remove from tracking
clearTimeout(timeoutId);
if (claudePid) {
currentSessionPids.delete(claudePid);
}
processOutput();
});
});
synapseDb.close();
log(`Run #${state.runCount} complete`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t record analysis runs when structured extraction fails.

If extraction fails, the code still updates session state and records analysis_runs, which can suppress retries and inflate usage streaks even though the run produced no actionable analysis. Consider treating this as a failed run (save session ID if needed, but skip recordAnalysisRun and streak updates).

Suggested fix
   if (!extracted.analysis) {
     log('Could not parse structured analysis from response');
     log(`Response preview: ${responseText.slice(0, 200)}`);
 
     // Check if this might be a session/context issue
     if (!isRetry && state.runCount >= MAX_SESSION_RUNS) {
       log('May be context overflow after many runs - retrying with fresh session');
       state.sessionId = null;
       state.runCount = 0;
       state.lastRetryAt = new Date().toISOString();
       saveSessionState(state);
       synapseDb.close();
       await runAnalysis(hours, mode, true);
       return;
     }
+
+    // Treat as a failed analysis: persist session metadata but skip run recording.
+    state.lastUsedAt = new Date().toISOString();
+    saveSessionState(state);
+    synapseDb.close();
+    return;
   } else {
     // ── Process structured analysis ────────────────────────────────
     await processStructuredAnalysis(extracted.analysis);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scripts/daemon.ts` around lines 1490 - 1541, The run currently treats
failed structured extraction as a successful run: after calling
extractAnalysisFromResponseText it still updates state.runCount/lastUsedAt,
calls saveSessionState, recordAnalysisRun, updates streaks and may send
notifications; instead, change the control flow so that when extracted.analysis
is falsy you do not increment state.runCount or update lastUsedAt and you do not
call recordAnalysisRun, getConsecutiveDaysStreak or sendNotification. Keep the
existing retry/reset logic (sessionId reset, saveSessionState when forcing a
fresh session) and always ensure synapseDb.close() runs, but move the state
updates, saveSessionState, recordAnalysisRun, streak check
(getConsecutiveDaysStreak) and reflection/notification logic into the branch
where extracted.analysis exists (after await processStructuredAnalysis) so only
successful extractions are recorded.

serrrfirat and others added 2 commits February 21, 2026 13:35
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Kept HEAD's harness-based analysis flow, added main's new blockedWindows/blockedUrls
config fields and userProfile to test fixtures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
.dmux-hooks/examples/run_test.example (1)

21-52: Use mktemp + trap for safer temp files and guaranteed cleanup.

A fixed /tmp filename risks collisions, and set -e can exit before cleanup. A temp file + trap avoids both.

♻️ Proposed fix
-OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt"
+OUTPUT_FILE="$(mktemp "/tmp/dmux-test-${DMUX_PANE_ID}.XXXXXX")"
+cleanup() { rm -f "$OUTPUT_FILE"; }
+trap cleanup EXIT
@@
-# Cleanup
-rm -f "$OUTPUT_FILE"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.dmux-hooks/examples/run_test.example around lines 21 - 52, Replace the
fixed /tmp filename with a safely created temporary file via mktemp (replace
OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt" with an mktemp-backed
assignment), and register a trap to remove that temp file on EXIT (to guarantee
cleanup even if the script exits early or set -e is enabled); keep the existing
test run flow and STATUS assignment logic (the if pnpm test > "$OUTPUT_FILE"
2>&1; then ... else ... fi and the curl reporting using $OUTPUT) but ensure the
trap runs before running tests so rm -f "$OUTPUT_FILE" is redundant and safe to
keep or remove. Ensure you reference the OUTPUT_FILE variable in the mktemp/trap
changes so the rest of the script continues to work.
.dmux-hooks/examples/post_merge.example (1)

34-41: Use a portable issue-number extractor (grep -oP is unavailable on macOS/BSD).

The -P flag is a GNU grep extension that doesn't exist on macOS or BSD systems (which ship FreeBSD grep). The proposed sed -nE replacement is portable and produces equivalent results:

♻️ Portable alternative
-ISSUE_NUM=$(echo "$DMUX_PROMPT" | grep -oP '#\K\d+' | head -1)
+ISSUE_NUM=$(printf '%s' "$DMUX_PROMPT" | sed -nE 's/.*#([0-9]+).*/\1/p' | head -1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.dmux-hooks/examples/post_merge.example around lines 34 - 41, Replace the
non-portable GNU grep usage that extracts the issue number with a portable
sed-based extractor: update the ISSUE_NUM assignment (which reads from
DMUX_PROMPT) to use sed -nE to capture the first sequence of digits following a
‘#’ and preserve the existing head -1 behavior; keep the rest of the hook logic
(the conditional on ISSUE_NUM, and the gh issue close call that references
DMUX_SLUG and DMUX_TARGET_BRANCH) unchanged so the hook behaves identically on
macOS/BSD and Linux.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.dmux-hooks/examples/run_dev.example:
- Around line 29-36: The port detection block using grep -oP is non-portable and
can cause the script to exit under set -e; replace the grep -oP call that
populates PORT with a portable pipeline (e.g., use grep -E or awk/sed to extract
the digits after "localhost:" from "$LOG_FILE") and ensure the command never
causes the script to abort (wrap with "|| true" or capture output via a
subshell) so the subsequent if [ -z "$PORT" ] fallback to PORT=3000 still runs;
update references to PORT and LOG_FILE accordingly and keep the head -1 behavior
to pick the first match.

In @.dmux-hooks/examples/worktree_created.example:
- Around line 36-38: Before appending to
"$DMUX_ROOT/.dmux/worktree_history.log", ensure the target directory exists by
creating "$DMUX_ROOT/.dmux" (e.g., mkdir -p using DMUX_ROOT variable) so the
echo redirect in the worktree_created script (the line writing to
.dmux/worktree_history.log) cannot fail under set -e; add that directory-create
step just prior to the echo that writes the log.

In `@src/index.ts`:
- Around line 353-370: The early return when harnessResult.ok is false skips
extracting usable rawText; update the logic around harnessResult, harnessUsed,
and harnessChain so that if harnessResult.ok is false but harnessResult.rawText
exists you still call extractAnalysisFromResponseText(responseText) and proceed
with extraction (and any downstream processing) before closing synapseDb and
returning. Adjust the control flow in the block that currently logs the error
and does synapseDb.close() so it only returns immediately when no rawText is
present, otherwise continue to the existing provider metadata/logging and
extraction steps (keeping references to harnessChain[0], harnessUsed,
harnessResult.metadata, and extractAnalysisFromResponseText).

In `@src/scripts/daemon.ts`:
- Around line 1463-1484: When harnessResult.ok is false we currently return
early and skip any salvageable parsing; update the !harnessResult.ok branch to
first check for harnessResult.rawText and attempt to run the existing
extraction/parsing routine (e.g., the function that normally converts raw
harness output into an analysis result — reference harnessResult.rawText and the
parser/extractor used elsewhere in this module) and proceed with the normal
success flow if extraction yields usable data; only if extraction fails should
you continue with the stale-session logic (isStaleSession, retry via
runAnalysis) and the current sendNotification/synapseDb.close return path,
ensuring you still preserve state.sessionId reset/saveSessionState and
synapseDb.close when falling back.

---

Duplicate comments:
In `@src/scripts/daemon.ts`:
- Around line 1500-1535: The code currently increments state.runCount, updates
state.lastUsedAt, saves session state, calls recordAnalysisRun, and logs
consecutive days even when extracted.analysis is falsy; move the session-state
updates (state.lastUsedAt, state.runCount++, state.mode and saveSessionState),
the recordAnalysisRun(...) call, and the getConsecutiveDaysStreak/log call into
the branch that handles successful extraction (the else that calls
processStructuredAnalysis(extracted.analysis)), so these actions only execute
when extracted.analysis exists; ensure the retry path that sets
state.sessionId=null and calls runAnalysis(...) still returns early and does not
run any of the moved post-success code.

---

Nitpick comments:
In @.dmux-hooks/examples/post_merge.example:
- Around line 34-41: Replace the non-portable GNU grep usage that extracts the
issue number with a portable sed-based extractor: update the ISSUE_NUM
assignment (which reads from DMUX_PROMPT) to use sed -nE to capture the first
sequence of digits following a ‘#’ and preserve the existing head -1 behavior;
keep the rest of the hook logic (the conditional on ISSUE_NUM, and the gh issue
close call that references DMUX_SLUG and DMUX_TARGET_BRANCH) unchanged so the
hook behaves identically on macOS/BSD and Linux.

In @.dmux-hooks/examples/run_test.example:
- Around line 21-52: Replace the fixed /tmp filename with a safely created
temporary file via mktemp (replace
OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt" with an mktemp-backed
assignment), and register a trap to remove that temp file on EXIT (to guarantee
cleanup even if the script exits early or set -e is enabled); keep the existing
test run flow and STATUS assignment logic (the if pnpm test > "$OUTPUT_FILE"
2>&1; then ... else ... fi and the curl reporting using $OUTPUT) but ensure the
trap runs before running tests so rm -f "$OUTPUT_FILE" is redundant and safe to
keep or remove. Ensure you reference the OUTPUT_FILE variable in the mktemp/trap
changes so the rest of the script continues to work.

Comment thread .dmux-hooks/examples/run_dev.example Outdated
Comment on lines +29 to +36
# Detect port from log output
# Adjust the grep pattern for your dev server's output format
PORT=$(grep -oP '(?<=localhost:)\d+' "$LOG_FILE" | head -1)

if [ -z "$PORT" ]; then
echo "[Hook] Warning: Could not detect port from logs, using default 3000"
PORT=3000
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and read the context around lines 29-36
if [ -f ".dmux-hooks/examples/run_dev.example" ]; then
  echo "=== File exists, showing context (lines 1-40) ==="
  head -40 ".dmux-hooks/examples/run_dev.example" | cat -n
else
  echo "File not found. Searching for similar files..."
  find . -name "*run_dev*" -o -name "*dmux*" 2>/dev/null | head -20
fi

Repository: serrrfirat/synapse-daemon

Length of output: 1499


🏁 Script executed:

# Test grep -oP portability on this system
echo "=== Testing grep -oP support ==="
echo "localhost:3000" | grep -oP '(?<=localhost:)\d+' 2>&1 && echo "SUCCESS: grep -oP works" || echo "FAILED: grep -oP not supported"

# Test the proposed alternative
echo ""
echo "=== Testing proposed alternative ==="
echo "localhost:3000" | grep -oE 'localhost:[0-9]+' | head -1 | sed -E 's/.*localhost:([0-9]+).*/\1/' || true
echo "Alternative result: $?"

Repository: serrrfirat/synapse-daemon

Length of output: 195


🏁 Script executed:

# Check if set -e is actually enabled in the script
if [ -f ".dmux-hooks/examples/run_dev.example" ]; then
  echo "=== Checking for 'set -e' in the script ==="
  grep -n "set -e" ".dmux-hooks/examples/run_dev.example" || echo "No 'set -e' found"
fi

Repository: serrrfirat/synapse-daemon

Length of output: 121


Port detection fails on macOS due to non-portable grep and set -e interaction.

grep -oP (Perl regex) is not supported by BSD grep (macOS default). When the pattern doesn't match, grep returns non-zero on macOS, causing the script to abort due to set -e before reaching the fallback to port 3000. The fallback check is unreachable in this scenario.

🐛 Recommended fix (portable alternative)
-PORT=$(grep -oP '(?<=localhost:)\d+' "$LOG_FILE" | head -1)
+PORT=$(grep -oE 'localhost:[0-9]+' "$LOG_FILE" | head -1 | sed -E 's/.*localhost:([0-9]+).*/\1/' || true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.dmux-hooks/examples/run_dev.example around lines 29 - 36, The port
detection block using grep -oP is non-portable and can cause the script to exit
under set -e; replace the grep -oP call that populates PORT with a portable
pipeline (e.g., use grep -E or awk/sed to extract the digits after "localhost:"
from "$LOG_FILE") and ensure the command never causes the script to abort (wrap
with "|| true" or capture output via a subshell) so the subsequent if [ -z
"$PORT" ] fallback to PORT=3000 still runs; update references to PORT and
LOG_FILE accordingly and keep the head -1 behavior to pick the first match.

Comment on lines +36 to +38
# Create a log entry
echo "[$(date)] Created worktree: $DMUX_SLUG | Agent: $DMUX_AGENT | Prompt: $DMUX_PROMPT" \
>> "$DMUX_ROOT/.dmux/worktree_history.log"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Ensure the history directory exists before appending.

set -e will abort if .dmux/ doesn’t exist.

🛠️ Proposed fix
-# Create a log entry
+# Create a log entry
+mkdir -p "$DMUX_ROOT/.dmux"
 echo "[$(date)] Created worktree: $DMUX_SLUG | Agent: $DMUX_AGENT | Prompt: $DMUX_PROMPT" \
   >> "$DMUX_ROOT/.dmux/worktree_history.log"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Create a log entry
echo "[$(date)] Created worktree: $DMUX_SLUG | Agent: $DMUX_AGENT | Prompt: $DMUX_PROMPT" \
>> "$DMUX_ROOT/.dmux/worktree_history.log"
# Create a log entry
mkdir -p "$DMUX_ROOT/.dmux"
echo "[$(date)] Created worktree: $DMUX_SLUG | Agent: $DMUX_AGENT | Prompt: $DMUX_PROMPT" \
>> "$DMUX_ROOT/.dmux/worktree_history.log"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.dmux-hooks/examples/worktree_created.example around lines 36 - 38, Before
appending to "$DMUX_ROOT/.dmux/worktree_history.log", ensure the target
directory exists by creating "$DMUX_ROOT/.dmux" (e.g., mkdir -p using DMUX_ROOT
variable) so the echo redirect in the worktree_created script (the line writing
to .dmux/worktree_history.log) cannot fail under set -e; add that
directory-create step just prior to the echo that writes the log.

Comment thread src/index.ts
Comment on lines +353 to 370
if (!harnessResult.ok) {
console.error(chalk.red(`Analysis failed: ${harnessResult.error}`));
synapseDb.close();
return;
}

claude.stdout.on('data', (data) => {
const chunk = data.toString();
output += chunk;
chunkCount++;
if (harnessUsed !== harnessChain[0]) {
console.log(chalk.yellow(`Fallback: used ${harnessUsed} instead of ${harnessChain[0]}`));
}

if (verbose) {
// Parse stream-json format: each line is a JSON object
const lines = chunk.split('\n').filter((l: string) => l.trim());
for (const line of lines) {
try {
const parsed = JSON.parse(line);
const eventType = `${parsed.type}${parsed.event?.type ? ':' + parsed.event.type : ''}`;
eventTypes.add(eventType);

// Handle stream_event with content_block_delta containing text_delta
// Format: {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}}
if (parsed.type === 'stream_event' &&
parsed.event?.type === 'content_block_delta' &&
parsed.event?.delta?.type === 'text_delta' &&
parsed.event?.delta?.text) {
const text = parsed.event.delta.text;
process.stdout.write(chalk.white(text));
fullText += text;
}
// Handle final assistant message (backup)
else if (parsed.type === 'assistant' && parsed.message?.content) {
for (const block of parsed.message.content) {
if (block.type === 'text' && block.text) {
fullText = block.text;
}
}
}
// Handle result type (final result with usage stats)
else if (parsed.type === 'result') {
if (parsed.result) {
fullText = parsed.result;
}
// Extract token usage
if (parsed.usage) {
tokenUsage = {
input: parsed.usage.input_tokens || 0,
output: parsed.usage.output_tokens || 0,
cacheRead: parsed.usage.cache_read_input_tokens || 0,
cacheCreate: parsed.usage.cache_creation_input_tokens || 0,
cost: parsed.total_cost_usd || 0
};
}
}
} catch {
// Not JSON or incomplete line, ignore
}
}
}
});
if (harnessResult.metadata?.model) {
console.log(chalk.gray(`Provider metadata: ${harnessResult.metadata.provider || harnessUsed}/${harnessResult.metadata.model}`));
}

let stderrOutput = '';
claude.stderr.on('data', (data) => {
const errStr = data.toString();
stderrOutput += errStr;
if (verbose) {
console.log(chalk.red(`[STDERR] ${errStr}`));
}
});
// ── Extract structured analysis from response text ──────────────────
const responseText = harnessResult.rawText || '';
const extracted = extractAnalysisFromResponseText(responseText);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Attempt extraction when a harness returns rawText but ok=false.

A non‑OK harness result may still include usable rawText (e.g., parser error). Currently the flow returns early and never attempts extraction.

🛠️ Proposed fix
-    if (!harnessResult.ok) {
-      console.error(chalk.red(`Analysis failed: ${harnessResult.error}`));
-      synapseDb.close();
-      return;
-    }
+    if (!harnessResult.ok && !harnessResult.rawText) {
+      console.error(chalk.red(`Analysis failed: ${harnessResult.error}`));
+      synapseDb.close();
+      return;
+    }
+    if (!harnessResult.ok && harnessResult.rawText) {
+      console.log(chalk.yellow(`Analysis returned non-OK status; attempting to extract from raw output...`));
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!harnessResult.ok) {
console.error(chalk.red(`Analysis failed: ${harnessResult.error}`));
synapseDb.close();
return;
}
claude.stdout.on('data', (data) => {
const chunk = data.toString();
output += chunk;
chunkCount++;
if (harnessUsed !== harnessChain[0]) {
console.log(chalk.yellow(`Fallback: used ${harnessUsed} instead of ${harnessChain[0]}`));
}
if (verbose) {
// Parse stream-json format: each line is a JSON object
const lines = chunk.split('\n').filter((l: string) => l.trim());
for (const line of lines) {
try {
const parsed = JSON.parse(line);
const eventType = `${parsed.type}${parsed.event?.type ? ':' + parsed.event.type : ''}`;
eventTypes.add(eventType);
// Handle stream_event with content_block_delta containing text_delta
// Format: {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}}
if (parsed.type === 'stream_event' &&
parsed.event?.type === 'content_block_delta' &&
parsed.event?.delta?.type === 'text_delta' &&
parsed.event?.delta?.text) {
const text = parsed.event.delta.text;
process.stdout.write(chalk.white(text));
fullText += text;
}
// Handle final assistant message (backup)
else if (parsed.type === 'assistant' && parsed.message?.content) {
for (const block of parsed.message.content) {
if (block.type === 'text' && block.text) {
fullText = block.text;
}
}
}
// Handle result type (final result with usage stats)
else if (parsed.type === 'result') {
if (parsed.result) {
fullText = parsed.result;
}
// Extract token usage
if (parsed.usage) {
tokenUsage = {
input: parsed.usage.input_tokens || 0,
output: parsed.usage.output_tokens || 0,
cacheRead: parsed.usage.cache_read_input_tokens || 0,
cacheCreate: parsed.usage.cache_creation_input_tokens || 0,
cost: parsed.total_cost_usd || 0
};
}
}
} catch {
// Not JSON or incomplete line, ignore
}
}
}
});
if (harnessResult.metadata?.model) {
console.log(chalk.gray(`Provider metadata: ${harnessResult.metadata.provider || harnessUsed}/${harnessResult.metadata.model}`));
}
let stderrOutput = '';
claude.stderr.on('data', (data) => {
const errStr = data.toString();
stderrOutput += errStr;
if (verbose) {
console.log(chalk.red(`[STDERR] ${errStr}`));
}
});
// ── Extract structured analysis from response text ──────────────────
const responseText = harnessResult.rawText || '';
const extracted = extractAnalysisFromResponseText(responseText);
if (!harnessResult.ok && !harnessResult.rawText) {
console.error(chalk.red(`Analysis failed: ${harnessResult.error}`));
synapseDb.close();
return;
}
if (!harnessResult.ok && harnessResult.rawText) {
console.log(chalk.yellow(`Analysis returned non-OK status; attempting to extract from raw output...`));
}
if (harnessUsed !== harnessChain[0]) {
console.log(chalk.yellow(`Fallback: used ${harnessUsed} instead of ${harnessChain[0]}`));
}
if (harnessResult.metadata?.model) {
console.log(chalk.gray(`Provider metadata: ${harnessResult.metadata.provider || harnessUsed}/${harnessResult.metadata.model}`));
}
// ── Extract structured analysis from response text ──────────────────
const responseText = harnessResult.rawText || '';
const extracted = extractAnalysisFromResponseText(responseText);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 353 - 370, The early return when harnessResult.ok
is false skips extracting usable rawText; update the logic around harnessResult,
harnessUsed, and harnessChain so that if harnessResult.ok is false but
harnessResult.rawText exists you still call
extractAnalysisFromResponseText(responseText) and proceed with extraction (and
any downstream processing) before closing synapseDb and returning. Adjust the
control flow in the block that currently logs the error and does
synapseDb.close() so it only returns immediately when no rawText is present,
otherwise continue to the existing provider metadata/logging and extraction
steps (keeping references to harnessChain[0], harnessUsed,
harnessResult.metadata, and extractAnalysisFromResponseText).

Comment thread src/scripts/daemon.ts
Comment on lines +1463 to +1484
if (!harnessResult.ok) {
log(`Analysis failed: ${harnessResult.error}`);
const isStaleSession = harnessResult.error && (
harnessResult.error.includes('no conversation found with session id') ||
harnessResult.error.includes('session not found') ||
harnessResult.error.includes('session_id')
);
if (!isRetry && isStaleSession) {
log('Stale session error detected - retrying once with fresh session.');
state.sessionId = null;
state.contextOverflow = false;
state.runCount = 0;
state.lastRetryAt = new Date().toISOString();
saveSessionState(state);
synapseDb.close();
await runAnalysis(hours, mode, true);
return;
}
sendNotification('Synapse Warning', `Analysis failed: ${harnessResult.error?.slice(0, 100)}`);
synapseDb.close();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Attempt extraction when rawText exists even if ok=false.

A non‑OK harness result can still contain parseable output; returning early skips that recovery path.

🛠️ Proposed fix
-  if (!harnessResult.ok) {
-    log(`Analysis failed: ${harnessResult.error}`);
+  if (!harnessResult.ok && !harnessResult.rawText) {
+    log(`Analysis failed: ${harnessResult.error}`);
     const isStaleSession = harnessResult.error && (
       harnessResult.error.includes('no conversation found with session id') ||
       harnessResult.error.includes('session not found') ||
       harnessResult.error.includes('session_id')
     );
@@
-    sendNotification('Synapse Warning', `Analysis failed: ${harnessResult.error?.slice(0, 100)}`);
-    synapseDb.close();
-    return;
-  }
+    sendNotification('Synapse Warning', `Analysis failed: ${harnessResult.error?.slice(0, 100)}`);
+    synapseDb.close();
+    return;
+  }
+  if (!harnessResult.ok && harnessResult.rawText) {
+    log('Analysis returned non-OK status; attempting to extract from raw output...');
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scripts/daemon.ts` around lines 1463 - 1484, When harnessResult.ok is
false we currently return early and skip any salvageable parsing; update the
!harnessResult.ok branch to first check for harnessResult.rawText and attempt to
run the existing extraction/parsing routine (e.g., the function that normally
converts raw harness output into an analysis result — reference
harnessResult.rawText and the parser/extractor used elsewhere in this module)
and proceed with the normal success flow if extraction yields usable data; only
if extraction fails should you continue with the stale-session logic
(isStaleSession, retry via runAnalysis) and the current
sendNotification/synapseDb.close return path, ensuring you still preserve
state.sessionId reset/saveSessionState and synapseDb.close when falling back.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant