Skip to content

Commit 01957ab

Browse files
fix(multiplexer): fix daemon startup crash from invalid claude_status JSON (shepherdjerred#178)
* fix(multiplexer): fix daemon startup crash from invalid claude_status JSON The daemon was crashing with "expected value at line 1 column 1" because the SQLite migration v3 set DEFAULT 'Unknown' for claude_status, but the code tried to parse it as JSON (which requires quoted "Unknown"). Changes: - Add FromStr impl for ClaudeWorkingStatus to parse raw enum variant names - Update session row parsing to try JSON first, then fall back to FromStr - Add detailed error messages showing which field failed and actual value - Add debug logging around database and session manager initialization - Improve client error handling for empty/malformed daemon responses - Add empty output check for gh pr checks in CI poller 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: decompiler --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ceadf7a commit 01957ab

10 files changed

Lines changed: 1021 additions & 31 deletions

File tree

packages/bun-decompile/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
out/
2+
out-*
23
.bun-decompile-cache/

packages/bun-decompile/LICENSE

Lines changed: 674 additions & 0 deletions
Large diffs are not rendered by default.

packages/bun-decompile/README.md

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# bun-decompile
2+
3+
Extract and de-minify source code from Bun-compiled executables.
4+
5+
Originally developed to inspect [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code), which ships as a Bun-compiled binary.
6+
7+
## Features
8+
9+
- **Binary Extraction**: Parse Bun's embedded module graph to extract all bundled sources
10+
- **Sourcemap Recovery**: Automatically recover original TypeScript/JSX sources from embedded sourcemaps
11+
- **AI De-minification**: Use OpenAI or Anthropic to rename minified identifiers back to meaningful names
12+
- **Functional Equivalence**: Babel-based renaming guarantees the de-minified code works identically
13+
14+
## Installation
15+
16+
```bash
17+
# Install locally
18+
bun add @shepherdjerred/bun-decompile
19+
20+
# Or install globally
21+
bun add -g @shepherdjerred/bun-decompile
22+
```
23+
24+
Requires [Bun](https://bun.sh) runtime.
25+
26+
## Quick Start
27+
28+
```bash
29+
# Extract sources from a compiled binary
30+
bun-decompile ./my-app -o ./extracted
31+
32+
# De-minify with OpenAI (default)
33+
export OPENAI_API_KEY=sk-...
34+
bun-decompile ./my-app --deminify
35+
36+
# De-minify with Anthropic Claude
37+
export ANTHROPIC_API_KEY=sk-ant-...
38+
bun-decompile ./my-app --deminify --provider anthropic
39+
40+
# Batch mode (Anthropic only, 50% cheaper)
41+
bun-decompile ./my-app --deminify --provider anthropic --batch
42+
43+
# De-minify a standalone JS file
44+
bun-decompile -f ./minified.js --deminify --yes
45+
```
46+
47+
## How It Works
48+
49+
### Binary Parsing
50+
51+
Bun embeds a module graph at the end of compiled executables. The parser:
52+
53+
1. Searches backwards from file end for the Bun trailer signature (`packages by bun`)
54+
2. Reads the offsets structure (32 bytes before trailer) containing:
55+
- Total embedded data size
56+
- Pointer to module array
57+
- Entry point index
58+
- Compile-time arguments
59+
3. Iterates the module entries (40 bytes each) extracting:
60+
- Module path and contents
61+
- Sourcemap and bytecode (if present)
62+
- Loader type (js/ts/jsx/tsx/css/json/etc)
63+
- Module format (ESM/CJS)
64+
65+
### Sourcemap Recovery
66+
67+
When sourcemaps are embedded (compiled with `--sourcemap`), the tool extracts original source files including:
68+
- TypeScript with full type annotations
69+
- JSX/TSX templates
70+
- Original comments and formatting
71+
72+
### AI De-minification Pipeline
73+
74+
The de-minification process uses a novel approach that guarantees functional equivalence:
75+
76+
1. **Call Graph Analysis**: Build a dependency graph of all functions in the source
77+
2. **Bottom-Up Processing**: Process leaf functions first, so parent functions see renamed callees
78+
3. **LLM Rename Suggestions**: The LLM analyzes function behavior and outputs JSON rename mappings:
79+
```json
80+
{
81+
"processItems_40_120": {
82+
"functionName": "filterValidItems",
83+
"description": "Filters array to valid items",
84+
"renames": { "t": "items", "r": "predicate", "n": "result" }
85+
}
86+
}
87+
```
88+
4. **Babel Transformation**: Babel's `scope.rename()` applies mappings, handling all scope complexity
89+
90+
This approach (inspired by [humanify](https://github.com/jehna/humanify)) means the LLM never outputs code directly—only suggestions—eliminating LLM-introduced bugs.
91+
92+
## CLI Reference
93+
94+
```
95+
bun-decompile <binary> [options]
96+
bun-decompile --file <js-file> --deminify [options]
97+
```
98+
99+
### Options
100+
101+
| Option | Description |
102+
|--------|-------------|
103+
| `-o, --output <dir>` | Output directory (default: `./decompiled`) |
104+
| `-f, --file <path>` | De-minify a JS file directly (skip binary extraction) |
105+
| `-v, --verbose` | Show detailed information |
106+
| `-q, --quiet` | Suppress progress display |
107+
| `-h, --help` | Show help message |
108+
109+
### De-minification Options
110+
111+
| Option | Description |
112+
|--------|-------------|
113+
| `--deminify` | Enable AI de-minification |
114+
| `--provider <name>` | LLM provider: `openai` or `anthropic` (default: openai) |
115+
| `--api-key <key>` | API key (or set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) |
116+
| `--model <model>` | Model to use (default: gpt-5-nano) |
117+
| `--batch` | Use Anthropic batch API (50% cheaper, async) |
118+
| `--resume <batch-id>` | Resume a pending batch job |
119+
| `--no-cache` | Disable result caching |
120+
| `--concurrency <n>` | Parallel API requests (default: 3, max: 20) |
121+
| `--yes` | Skip cost confirmation prompt |
122+
123+
124+
## Programmatic API
125+
126+
### Extraction
127+
128+
```typescript
129+
import {
130+
decompileFile,
131+
extractToDirectory,
132+
getExtractionSummary,
133+
} from "@shepherdjerred/bun-decompile";
134+
135+
// Parse binary and extract module graph
136+
const result = await decompileFile("./my-app");
137+
138+
console.log(getExtractionSummary(result));
139+
// Bun Version: 1.2.0
140+
// Bundled Modules: 42
141+
// Entry Point: /src/index.ts
142+
// Original Sources: 38 (recovered from sourcemap)
143+
144+
// Write to disk
145+
await extractToDirectory(result, "./extracted");
146+
```
147+
148+
### De-minification
149+
150+
```typescript
151+
import {
152+
createConfig,
153+
Deminifier,
154+
} from "@shepherdjerred/bun-decompile";
155+
156+
const config = createConfig(process.env.OPENAI_API_KEY!, "./output", {
157+
provider: "openai",
158+
model: "gpt-5-nano",
159+
verbose: true,
160+
});
161+
162+
const deminifier = new Deminifier(config);
163+
164+
// Estimate cost before processing
165+
const estimate = deminifier.estimateCost(minifiedSource);
166+
console.log(`Estimated cost: $${estimate.estimatedCost.toFixed(4)}`);
167+
168+
// De-minify with progress tracking
169+
const result = await deminifier.deminifyFile(minifiedSource, {
170+
fileName: "bundle.js",
171+
onExtendedProgress: (progress) => {
172+
console.log(`${progress.phase}: ${progress.current}/${progress.total}`);
173+
},
174+
});
175+
176+
console.log(deminifier.getStats());
177+
```
178+
179+
### Key Exports
180+
181+
- **Extraction**: `decompileFile`, `decompile`, `extractToDirectory`, `parseSourceMap`
182+
- **De-minification**: `Deminifier`, `createConfig`, `ClaudeClient`, `OpenAIClient`
183+
- **Types**: `DecompileResult`, `ModuleEntry`, `DeminifyConfig`, `CostEstimate`
184+
185+
## Output Structure
186+
187+
```
188+
output/
189+
├── metadata.json # Bun version, entry point, module counts
190+
├── bundled/ # Transpiled/bundled JS sources + sourcemaps
191+
├── original/ # Original TS/TSX sources (from sourcemaps)
192+
├── deminified/ # AI de-minified output
193+
├── bytecode/ # Pre-compiled bytecode (if present)
194+
└── cache/ # LLM response cache
195+
```
196+
197+
## Development
198+
199+
```bash
200+
bun test # Run tests
201+
bun run typecheck # Type check
202+
```
203+
204+
### Project Structure
205+
206+
- `src/index.ts` - CLI entry point
207+
- `src/lib/parser.ts` - Binary parsing
208+
- `src/lib/extractor.ts` - File extraction
209+
- `src/lib/deminify/` - AI de-minification (deminifier, babel-renamer, call-graph, LLM clients)
210+
211+
## License
212+
213+
GPL-3.0

packages/bun-decompile/src/lib/deminify/deminifier.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -423,13 +423,7 @@ export class Deminifier {
423423

424424
const result = await processor.processAll(source, graph, {
425425
// maxBatchTokens computed from model context limit if not specified
426-
<<<<<<< Updated upstream
427426
...(options?.maxBatchTokens !== undefined ? { maxBatchTokens: options.maxBatchTokens } : {}),
428-
||||||| Stash base
429-
maxBatchTokens: options?.maxBatchTokens,
430-
=======
431-
...(options?.maxBatchTokens !== undefined && { maxBatchTokens: options.maxBatchTokens }),
432-
>>>>>>> Stashed changes
433427
verbose: this.config.verbose,
434428
onProgress: (progress) => {
435429
const progressUpdate: DeminifyProgress = {

packages/bun-decompile/src/lib/deminify/openai-batch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ interface BatchRequest {
5959
body: {
6060
model: string;
6161
messages: Array<{ role: "system" | "user"; content: string }>;
62-
max_tokens?: number;
62+
max_completion_tokens?: number;
6363
};
6464
}
6565

@@ -132,7 +132,7 @@ export class OpenAIBatchClient {
132132
{ role: "system", content: systemPrompt },
133133
{ role: "user", content: userPrompt },
134134
],
135-
max_tokens: this.config.maxTokens,
135+
max_completion_tokens: this.config.maxTokens,
136136
},
137137
});
138138
}

packages/multiplexer/src/api/client.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,25 @@ impl Client {
6161

6262
let mut reader = BufReader::new(reader);
6363
let mut line = String::new();
64-
reader.read_line(&mut line).await?;
64+
let bytes_read = reader.read_line(&mut line).await?;
6565

66-
let response: Response = serde_json::from_str(line.trim())?;
66+
if bytes_read == 0 {
67+
anyhow::bail!("Daemon closed connection unexpectedly (0 bytes read)");
68+
}
69+
70+
let trimmed = line.trim();
71+
if trimmed.is_empty() {
72+
anyhow::bail!("Daemon returned empty response (read {} bytes, trimmed to empty)", bytes_read);
73+
}
74+
75+
let response: Response = serde_json::from_str(trimmed).map_err(|e| {
76+
anyhow::anyhow!(
77+
"Failed to parse daemon response: {}. Raw response ({} bytes): {:?}",
78+
e,
79+
trimmed.len(),
80+
if trimmed.len() > 200 { &trimmed[..200] } else { trimmed }
81+
)
82+
})?;
6783
Ok(response)
6884
}
6985

packages/multiplexer/src/api/server.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@ pub async fn run_daemon() -> anyhow::Result<()> {
2929
/// bound, or other I/O errors occur.
3030
pub async fn run_daemon_with_options(enable_proxy: bool) -> anyhow::Result<()> {
3131
// Initialize the store
32+
tracing::debug!("Initializing database store...");
3233
let db_path = paths::database_path();
33-
let store = Arc::new(SqliteStore::new(&db_path).await?);
34+
let store = Arc::new(SqliteStore::new(&db_path).await.map_err(|e| {
35+
tracing::error!("Failed to initialize database at {:?}: {}", db_path, e);
36+
e
37+
})?);
38+
tracing::debug!("Database store initialized successfully");
3439

3540
// Initialize proxy services if enabled
3641
let proxy_manager: Option<Arc<ProxyManager>> = if enable_proxy {
@@ -56,21 +61,31 @@ pub async fn run_daemon_with_options(enable_proxy: bool) -> anyhow::Result<()> {
5661
};
5762

5863
// Initialize the session manager with proxy support if available
64+
tracing::debug!("Initializing session manager...");
5965
let manager = if let Some(ref pm) = proxy_manager {
6066
let docker_proxy_config = DockerProxyConfig::new(
6167
pm.http_proxy_port(),
6268
pm.mux_dir().clone(),
6369
);
6470
let docker_backend = DockerBackend::with_proxy(docker_proxy_config);
65-
let mut session_manager = SessionManager::with_docker_backend(store, docker_backend).await?;
71+
let mut session_manager = SessionManager::with_docker_backend(store, docker_backend)
72+
.await
73+
.map_err(|e| {
74+
tracing::error!("Failed to initialize session manager: {}", e);
75+
e
76+
})?;
6677

6778
// Wire up proxy manager for per-session filtering
6879
session_manager.set_proxy_manager(Arc::clone(pm));
6980

7081
Arc::new(session_manager)
7182
} else {
72-
Arc::new(SessionManager::with_defaults(store).await?)
83+
Arc::new(SessionManager::with_defaults(store).await.map_err(|e| {
84+
tracing::error!("Failed to initialize session manager (no proxy): {}", e);
85+
e
86+
})?)
7387
};
88+
tracing::info!("Session manager initialized");
7489

7590
// Create the socket path
7691
let socket_path = paths::socket_path();

packages/multiplexer/src/ci/poller.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,17 @@ impl CIPoller {
8787
}
8888

8989
let json_output = String::from_utf8_lossy(&output.stdout);
90-
let checks: Vec<serde_json::Value> = serde_json::from_str(&json_output)?;
90+
if json_output.trim().is_empty() {
91+
tracing::debug!(pr_url = %pr_url, "gh pr checks returned empty output");
92+
return Ok(());
93+
}
94+
let checks: Vec<serde_json::Value> = serde_json::from_str(&json_output).map_err(|e| {
95+
anyhow::anyhow!(
96+
"Failed to parse gh pr checks output: {}. Raw output: {:?}",
97+
e,
98+
if json_output.len() > 200 { &json_output[..200] } else { &json_output }
99+
)
100+
})?;
91101

92102
// Determine overall status
93103
let new_status = if checks.is_empty() {

packages/multiplexer/src/core/session.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,10 @@ pub enum CheckStatus {
227227

228228
/// Claude agent working status
229229
#[typeshare]
230-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
231231
pub enum ClaudeWorkingStatus {
232232
/// Unknown state (no hooks configured or no data yet)
233+
#[default]
233234
Unknown,
234235

235236
/// Claude is actively working (PreToolUse hook triggered)
@@ -245,6 +246,21 @@ pub enum ClaudeWorkingStatus {
245246
Idle,
246247
}
247248

249+
impl std::str::FromStr for ClaudeWorkingStatus {
250+
type Err = anyhow::Error;
251+
252+
fn from_str(s: &str) -> Result<Self, Self::Err> {
253+
match s {
254+
"Unknown" => Ok(Self::Unknown),
255+
"Working" => Ok(Self::Working),
256+
"WaitingApproval" => Ok(Self::WaitingApproval),
257+
"WaitingInput" => Ok(Self::WaitingInput),
258+
"Idle" => Ok(Self::Idle),
259+
_ => anyhow::bail!("unknown ClaudeWorkingStatus: {}", s),
260+
}
261+
}
262+
}
263+
248264
/// Access mode for proxy filtering
249265
#[typeshare]
250266
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

0 commit comments

Comments
 (0)