Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 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";
}
}
62 changes: 62 additions & 0 deletions packages/adapters/claude/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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".
*/
// curl runs as a child (not `exec`) so the trailing `|| exit 0` actually fires:
// with `exec`, the shell is replaced by curl and a non-zero curl exit (refused
// connection, 3s timeout, DNS) would propagate as a failed hook. As a child,
// any curl failure is swallowed and the hook exits 0 β€” "failure is a no-op".
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"
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 || true
exit 0
`;

/**
* 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 });

// Absolute path: Claude fires hooks from whatever directory the agent has
// `cd`'d into, so a relative `.middle/hooks/hook.sh` would fail to resolve
// from a subdirectory and silently skip the POST (β†’ awaitStop times out).
// Double-quote the path so a worktree under a home dir with spaces
// (e.g. /Users/Jane Doe/...) doesn't mis-parse the hook command.
const settings = {
hooks: {
SessionStart: [
{ hooks: [{ type: "command", command: `"${scriptPath}" session.started` }] },
],
Stop: [{ hooks: [{ type: "command", command: `"${scriptPath}" 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.

}
102 changes: 100 additions & 2 deletions packages/adapters/claude/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,101 @@
// @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 { capturePane, pollPaneFor, sendKeys } from "@middle/core";
import { classifyStop } from "./classify.ts";
import { installHooks } from "./hooks.ts";
import { buildPromptText } from "./prompt.ts";
import { readTranscriptState, resolveTranscriptPath } from "./transcript.ts";

/**
* `--dangerously-skip-permissions` is the auto-mode flag β€” runtime-equivalent
* to bypassPermissions, but still pops a one-time "are you sure?" warning at
* boot. `enterAutoMode` dismisses it.
*/
const AUTO_MODE_FLAG = "--dangerously-skip-permissions";

const BYPASS_PROMPT_RE = /bypass\s+permissions?|skip\s+permissions?|dangerously/i;
const NEEDS_LOGIN_RE =
/please\s+(?:run\s+|use\s+)?(?:claude\s+)?\/?(?:login|sign[ -]?in)|not\s+(?:logged\s+in|authenticated|signed\s+in)|welcome\s+to\s+claude\s+code.*sign|invalid\s+(?:api\s+key|credentials)/i;

/** Whether a captured pane shows Claude's bypass-mode confirmation prompt. */
export function detectBypassPrompt(paneContent: string): boolean {
return BYPASS_PROMPT_RE.test(paneContent);
}

/** Whether a captured pane shows a "you need to log in" message. */
export function detectNeedsLogin(paneContent: string): boolean {
return NEEDS_LOGIN_RE.test(paneContent);
}

/** Long polling window β€” covers Claude's slowest boot up to launchTimeout. */
const BOOT_DETECT_TIMEOUT_MS = 90_000;

type BootOutcome = "bypass-prompt" | "needs-login";

/**
* Pre-SessionStart boot polling. Runs in parallel with `awaitSessionStart`
* because Claude does not fire SessionStart until past the bypass-mode
* warning. Two outcomes drive action:
*
* - `bypass-prompt`: send Down + Enter (split with a 100ms delay so the menu
* has time to advance selection between keys) to select "Yes, I accept".
* Claude proceeds and fires SessionStart shortly after.
* - `needs-login`: throw a clean error so `mm dispatch` exits with a useful
* "claude is not authenticated" message instead of hanging on a 90s
* SessionStart timeout.
*/
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 tag = `claude:${opts.sessionName}`;
const outcome = await pollPaneFor<BootOutcome>(
opts.sessionName,
(pane) => {
if (detectNeedsLogin(pane)) return "needs-login";
if (detectBypassPrompt(pane)) return "bypass-prompt";
return null;
},
{ timeoutMs: BOOT_DETECT_TIMEOUT_MS, pollIntervalMs: 200, tag },
);

if (outcome === "needs-login") {
throw new Error(
"claude is not authenticated β€” run `claude` interactively in a normal terminal to sign in, then retry the dispatch",
);
}
if (outcome === "bypass-prompt") {
console.error(`[${tag}] bypass prompt detected β€” settling then Down then Enter`);
await Bun.sleep(200);
await sendKeys(opts.sessionName, ["Down", "Enter"], { delayBetweenMs: 100 });
// Post-keystroke capture confirms whether the menu actually advanced.
await Bun.sleep(300);
const after = await capturePane(opts.sessionName);
const afterTail = (after ?? "<capture failed>").replace(/\s+/g, " ").trim().slice(-300);
console.error(`[${tag}] post-keystroke pane tail: "${afterTail}"`);
}
// outcome null: neither prompt nor login screen appeared. SessionStart should
// already have fired (or be about to). enterAutoMode has nothing else to do.
}
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. `--dangerously-skip-permissions`
// engages auto mode AND suppresses the API-permission gate (the bypass
// confirmation TUI is separate, dismissed via enterAutoMode). Env is
// injected by tmux at spawn time.
return {
argv: ["claude", AUTO_MODE_FLAG],
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