-
Notifications
You must be signed in to change notification settings - Fork 1
feat(dispatcher): minimal dispatcher β Phase 1 (Epic #6) #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b63519d
ec8aa0c
ea57c4c
93e3fc0
f35aaeb
7419333
67565f4
91f3233
57dfe1b
1f99447
d262897
435b2d7
29fa548
1dd4e7f
95892f1
d461ceb
a699c73
24b630a
7487b54
15141c4
0beedf6
5b23056
417c195
9f84cd6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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` | ||
| * 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 { | ||
|
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"; | ||
| } | ||
| } | ||
| 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`); | ||
|
greptile-apps[bot] marked this conversation as resolved.
Comment on lines
+52
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The The fix is to store an absolute path in
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5b23056. The |
||
| } | ||
| 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> { | ||
| 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. | ||
| } | ||
|
|
||
| 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, | ||
| }; |
| 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}`; | ||
| } | ||
| } |
| 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 }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Decision:
done/failedare detected via.middle/done.json/.middle/failed.jsonsentinels in Phase 1. The spec describesclassifyStopas "reads PR state fordone", 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.jsonquestion sentinel β keep everyclassifyStopbranch deterministically classifiable and unit-testable. Phase 4's mechanically-enforced PR-ready hook gate replaces thedone.jsonpath with the real "agent rangh pr ready" signal.