-
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 9 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,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 | ||
|
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.
When
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. Dropped |
||
| `; | ||
|
|
||
| /** | ||
| * 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`); | ||
|
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,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> { | ||
|
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. Decision: |
||
| 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()}`, | ||
| ); | ||
| } | ||
| } | ||
|
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, | ||
| }; | ||
| 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.