Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b63519d
docs(dispatcher): add Epic #6 implementation plan
thejustinwalsh May 15, 2026
ec8aa0c
feat(dispatcher): SQLite WAL db wrapper + 001_initial migration
thejustinwalsh May 15, 2026
ea57c4c
feat(core): TOML config loader with global + per-repo merge
thejustinwalsh May 15, 2026
93e3fc0
feat(core): AgentAdapter interface + ClaudeAdapter (launch, classify,…
thejustinwalsh May 15, 2026
f35aaeb
feat(dispatcher): tmux session helpers (launch, send-keys, capture, s…
thejustinwalsh May 15, 2026
7419333
feat(dispatcher): git worktree helpers (create, destroy, list)
thejustinwalsh May 15, 2026
67565f4
feat(dispatcher): 3-step implementation workflow + minimal hook receiver
thejustinwalsh May 15, 2026
91f3233
feat(cli): mm start/stop/status + dispatch commands
thejustinwalsh May 15, 2026
57dfe1b
fix(dispatcher): six review fixes β€” Stop hook, sentinel anchoring, cl…
thejustinwalsh May 15, 2026
1f99447
fix(cli): wrap dispatchEpic in try/catch so EADDRINUSE prints a frien…
thejustinwalsh May 15, 2026
d262897
fix(adapter-claude): enter auto mode via --permission-mode flag, not …
thejustinwalsh May 15, 2026
435b2d7
fix(dispatcher): bunqueue lifecycle race β€” engine.close(false) + retr…
thejustinwalsh May 15, 2026
29fa548
feat(cli): mm doctor β€” preflight tmux/claude/git/gh + version threshold
thejustinwalsh May 15, 2026
1dd4e7f
fix(adapter-claude): use --dangerously-skip-permissions to skip the b…
thejustinwalsh May 15, 2026
95892f1
fix(adapter-claude): detect bypass-mode prompt and answer with Down+E…
thejustinwalsh May 15, 2026
d461ceb
chore(adapter-claude): instrument enterAutoMode polling for diagnosis
thejustinwalsh May 15, 2026
a699c73
chore(dispatcher): log workflow stage transitions + hook arrivals; bu…
thejustinwalsh May 15, 2026
24b630a
fix(dispatcher): run bypass-prompt dismisser in parallel with Session…
thejustinwalsh May 15, 2026
7487b54
fix(dispatcher): drain bunqueue inline before tearing down hook serve…
thejustinwalsh May 16, 2026
15141c4
chore(adapter-claude): split Down+Enter with delays + log post-keystr…
thejustinwalsh May 16, 2026
0beedf6
feat(core): shared TUI primitives; claude: detectNeedsLogin; dispatch…
thejustinwalsh May 16, 2026
5b23056
fix: second review round β€” hook script, abs hook path, pid-write orde…
thejustinwalsh May 22, 2026
417c195
fix: CodeRabbit review β€” pid guards, db handle leak, engine leak, pat…
thejustinwalsh May 22, 2026
9f84cd6
test(cli): assert fixture git init/commit succeed in dispatch EADDRIN…
thejustinwalsh May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions packages/adapters/claude/src/classify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { HookPayload, StopClassification } from "@middle/core";

const USAGE_LIMIT_RE = /You've hit your usage limit\. Resets at (.+?)\./;

/**
* Classify the agent's state at a `Stop` hook. The interactive process does not
* exit between turns, so this β€” not an exit code β€” is the signal the workflow
* reacts to. Order matters: an open question outranks everything else.
*
* All three sentinel paths are anchored at `<worktree>/.middle/`, not at
* `payload.cwd`. The Claude session's `cwd` at Stop time may be a subdirectory
* the agent has `cd`'d into (e.g. `worktree/src/`); only the worktree root is
* the stable home of the workstream's sentinel files.
*
* Phase 1 detects `done`/`failed` via `.middle/done.json` / `.middle/failed.json`

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.

Decision: done/failed are detected via .middle/done.json / .middle/failed.json sentinels in Phase 1. The spec describes classifyStop as "reads PR state for done", but the fixed interface ({ payload, transcriptPath, sentinelPresent }) gives the adapter no PR handle, and Phase 1 ships no skill enforcement. Sentinels β€” parallel to the .middle/blocked.json question sentinel β€” keep every classifyStop branch deterministically classifiable and unit-testable. Phase 4's mechanically-enforced PR-ready hook gate replaces the done.json path with the real "agent ran gh pr ready" signal.

* sentinels, parallel to the `.middle/blocked.json` question sentinel. Phase 4
* replaces the `done` path with the mechanically-enforced PR-ready hook gate.
*/
export function classifyStop(opts: {
payload: HookPayload;
transcriptPath: string;
sentinelPresent: boolean;
worktree: string;
}): StopClassification {
const middleDir = join(opts.worktree, ".middle");

if (opts.sentinelPresent) {
return { kind: "asked-question", sentinelPath: join(middleDir, "blocked.json") };
}

const match = USAGE_LIMIT_RE.exec(readTail(opts.transcriptPath));
if (match) return { kind: "rate-limited", resetAt: match[1]! };

if (existsSync(join(middleDir, "done.json"))) return { kind: "done" };

const failedPath = join(middleDir, "failed.json");
if (existsSync(failedPath)) {
return { kind: "failed", reason: readFailedReason(failedPath) };
}

return { kind: "bare-stop" };
}

function readTail(path: string): string {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
try {
const raw = readFileSync(path, "utf8");
return raw.length > 8192 ? raw.slice(-8192) : raw;
} catch {
return "";
}
}

function readFailedReason(path: string): string {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as { reason?: unknown };
return typeof parsed.reason === "string" ? parsed.reason : "agent reported failure";
} catch {
return "agent reported failure";
}
}
54 changes: 54 additions & 0 deletions packages/adapters/claude/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { chmod, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { InstallHookOpts } from "@middle/core";

/**
* The universal hook script β€” POSTs the hook payload to the dispatcher. Args:
* `$1` is the normalized event name. Never blocks the agent (3s timeout,
* failure β†’ exit 0). Source of truth: build spec β†’ "Normalized event taxonomy".
*/
const HOOK_SCRIPT = `#!/bin/sh
# .middle/hooks/hook.sh β€” POSTs hook payloads to the middle dispatcher.
# Args: $1 = normalized event name. Never blocks the agent; failure is a no-op.
EVENT="$1"
exec curl -sS -X POST "\${MIDDLE_DISPATCHER_URL}/hooks/\${EVENT}" \\
-H "X-Middle-Session: \${MIDDLE_SESSION}" \\
-H "X-Middle-Token: \${MIDDLE_SESSION_TOKEN}" \\
-H "X-Middle-Epic: \${MIDDLE_EPIC}" \\
-H "Content-Type: application/json" \\
--data-binary @- --max-time 3 || exit 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 || exit 0 is dead code after exec

When exec curl succeeds, the shell process is replaced by curl β€” the shell is gone before || exit 0 can ever be evaluated. So if curl exits non-zero (connection refused, 3-second --max-time timeout, DNS failure), the hook exits with that non-zero code rather than 0, directly contradicting the "failure is a no-op" contract documented in the comment above. The || exit 0 only fires in the narrow path where exec itself fails (e.g., curl not in PATH), and only on shells where exec-failure is non-fatal. Dropping exec so curl runs as a child process lets the shell honour || exit 0 on all curl failures.

Fix in Claude Code Fix in Codex

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.

Fixed in 5b23056. Dropped exec β€” curl now runs as a child so the trailing || true (then explicit exit 0) actually fires. A non-zero curl exit (refused / 3s timeout / DNS) is now swallowed and the hook exits 0, honoring the documented "failure is a no-op" contract.

`;

/**
* Phase 1 install: write the universal hook script into the worktree and a
* minimal `.claude/settings.json` registering the two load-bearing events the
* `implementation` workflow depends on:
*
* - `SessionStart` β†’ `session.started` β€” discovers `session_id` + `transcript_path`
* - `Stop` β†’ `agent.stopped` β€” the turn boundary `classifyStop` reacts to
*
* Phase 2 expands to the full event taxonomy, HMAC auth, and merging into any
* pre-existing settings file.
*/
export async function installHooks(opts: InstallHookOpts): Promise<void> {
const scriptPath = join(opts.worktree, opts.hookScriptPath);
await mkdir(dirname(scriptPath), { recursive: true });
await Bun.write(scriptPath, HOOK_SCRIPT);
await chmod(scriptPath, 0o755);

const claudeDir = join(opts.worktree, ".claude");
await mkdir(claudeDir, { recursive: true });

const settings = {
hooks: {
SessionStart: [
{ hooks: [{ type: "command", command: `${opts.hookScriptPath} session.started` }] },
],
Stop: [
{ hooks: [{ type: "command", command: `${opts.hookScriptPath} agent.stopped` }] },
],
},
};

await Bun.write(join(claudeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +52 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Relative hookScriptPath silently breaks Stop delivery when the agent changes directory

The Stop hook command written into .claude/settings.json is .middle/hooks/hook.sh agent.stopped β€” a path relative to the CWD at hook-fire time. Claude agents routinely cd into subdirectories while working (the classifyStop fix in this PR already acknowledges that payload.cwd is "a subdirectory the agent has cd'd into"). When Claude fires the Stop hook from a subdirectory (e.g., <worktree>/src/), the shell looks for .middle/hooks/hook.sh relative to that directory, finds nothing, and exits non-zero. The || exit 0 inside the script body never executes β€” it's on the exec curl line, not on the script-not-found path. Claude's hook exits without POSTing to the dispatcher, awaitStop waits the full 4-hour timeout, and the workflow compensates instead of completing.

The fix is to store an absolute path in settings.json. Since installHooks already has opts.worktree, the command should be join(opts.worktree, opts.hookScriptPath) rather than bare opts.hookScriptPath.

Fix in Claude Code Fix in Codex

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.

Fixed in 5b23056. The .claude/settings.json hook command is now the absolute join(worktree, hookScriptPath) instead of the bare relative path, so it resolves regardless of which subdirectory the agent has cd'd into when the Stop hook fires.

}
55 changes: 53 additions & 2 deletions packages/adapters/claude/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,54 @@
// @middle/adapter-claude β€” implements AgentAdapter for the Claude CLI.
// Source lands in build-spec Phase 1 (spawn + classify) and Phase 2 (hooks).
export {};
import type { AgentAdapter } from "@middle/core";
import { classifyStop } from "./classify.ts";
import { installHooks } from "./hooks.ts";
import { buildPromptText } from "./prompt.ts";
import { readTranscriptState, resolveTranscriptPath } from "./transcript.ts";

/**
* Bring the ready session into auto mode. Claude's interactive mode honors no
* launch flag for this, so the guaranteed path is two Shift-Tab keystrokes sent
* into the live tmux session. The adapter shells out to `tmux` directly rather
* than depending on the dispatcher's richer helper module β€” entering auto mode
* is intrinsically a per-CLI keystroke concern the adapter owns.
*
* A non-zero exit from `tmux send-keys` (missing session, tmux not on PATH)
* throws so `launchAndDrive`'s catch can kill the session and compensate β€”
* otherwise the workflow would silently proceed to send the prompt into a
* session that never entered auto mode and never reaches `Stop`.
*/
async function enterAutoMode(opts: { sessionName: string }): Promise<void> {

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.

Decision: enterAutoMode shells out to tmux directly, not via the dispatcher's tmux.ts helper. @middle/adapter-claude depends on @middle/core only; the tmux helpers live in @middle/dispatcher, and an adapter→dispatcher edge would invert the layering. Entering auto mode is intrinsically a per-CLI keystroke concern the adapter owns. The S-Tab S-Tab keystroke path is the guaranteed fallback the spec calls for; whether a launch flag also works is the spec's open empirical question, resolved when the real-Claude end-to-end is exercised. Not unit-tested (needs a live tmux session).

const proc = Bun.spawn(["tmux", "send-keys", "-t", opts.sessionName, "S-Tab", "S-Tab"], {
stdout: "ignore",
stderr: "pipe",
});
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(
`enterAutoMode: tmux send-keys to "${opts.sessionName}" failed (exit ${exitCode}): ${stderr.trim()}`,
);
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

export const claudeAdapter: AgentAdapter = {
name: "claude",
readyEvent: "session.started",
installHooks,
buildLaunchCommand(opts) {
// Interactive β€” no `-p`, no prompt. Env is injected by tmux at spawn time.
return {
argv: ["claude"],
env: {
MIDDLE_SESSION: opts.sessionName,
MIDDLE_SESSION_TOKEN: opts.sessionToken,
...opts.envOverrides,
},
};
},
buildPromptText,
enterAutoMode,
resolveTranscriptPath,
readTranscriptState,
classifyStop,
};
21 changes: 21 additions & 0 deletions packages/adapters/claude/src/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* The literal text `send-keys` carries into a tmux session to start or continue
* the agent. `send-keys` cannot cleanly carry a multi-line prompt β€” embedded
* newlines submit early β€” so the full prompt lives on disk and this returns a
* one-line `@`-reference that force-includes it. A single `@` prefixes the
* whole relative path.
*/
export function buildPromptText(opts: {
promptFile: string;
kind: "initial" | "resume" | "answer";
}): string {
const ref = `@${opts.promptFile}`;
switch (opts.kind) {
case "initial":
return ref;
case "resume":
return `Resuming this workstream β€” re-read the linked context and continue. ${ref}`;
case "answer":
return `A human answered your open question β€” read the answer and continue. ${ref}`;
}
}
76 changes: 76 additions & 0 deletions packages/adapters/claude/src/transcript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { readFileSync } from "node:fs";
import type { HookPayload, TranscriptState } from "@middle/core";

/** Claude delivers `transcript_path` directly in the SessionStart hook payload. */
export function resolveTranscriptPath(payload: HookPayload): string {
const path = payload.transcript_path;
if (typeof path !== "string" || path.length === 0) {
throw new Error("SessionStart payload has no transcript_path");
}
return path;
}

type TranscriptLine = {
type?: string;
timestamp?: string;
message?: {
role?: string;
content?: unknown;
usage?: Record<string, number>;
};
};

function isToolUseBlock(block: unknown): block is { type: "tool_use"; name?: string } {
return (
typeof block === "object" &&
block !== null &&
(block as { type?: string }).type === "tool_use"
);
}

/**
* Parse the JSONL transcript for activity, turn count, last tool use, and
* context-token usage. Corrupt lines are skipped rather than thrown on β€” the
* transcript reconciler cron (Phase 2) is the authoritative reader; this is the
* fast-path estimate. `contextTokens` is the input side of the last assistant
* turn (prompt + cache), i.e. how full the context window is.
*/
export function readTranscriptState(transcriptPath: string): TranscriptState {
const raw = readFileSync(transcriptPath, "utf8");
let lastActivity = "";
let turnCount = 0;
let lastToolUse: string | null = null;
let contextTokens = 0;

for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") continue;
let entry: TranscriptLine;
try {
entry = JSON.parse(trimmed) as TranscriptLine;
} catch {
continue;
}
if (typeof entry.timestamp === "string") lastActivity = entry.timestamp;
if (entry.type !== "assistant") continue;

turnCount++;
const content = entry.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
if (isToolUseBlock(block) && typeof block.name === "string") {
lastToolUse = block.name;
}
}
}
const usage = entry.message?.usage;
if (usage) {
contextTokens =
(usage.input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0);
}
}

return { lastActivity, contextTokens, turnCount, lastToolUse };
}
Loading