-
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 8 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,57 @@ | ||
| 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. | ||
| * | ||
| * 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; | ||
| }): StopClassification { | ||
| const cwd = typeof opts.payload.cwd === "string" ? opts.payload.cwd : ""; | ||
| const middleDir = join(cwd, ".middle"); | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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,27 @@ | ||
| import { mkdir } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| import type { InstallHookOpts } from "@middle/core"; | ||
|
|
||
| /** | ||
| * Phase 1 stub: write a `SessionStart`-only `.claude/settings.json` into the | ||
| * worktree. SessionStart is the one load-bearing hook for this phase β it | ||
| * carries `session_id` and `transcript_path`, which is how the dispatcher | ||
| * discovers the transcript. Phase 2 expands this to the full event taxonomy | ||
| * with HMAC auth and merges into any pre-existing settings file. | ||
| */ | ||
| export async function installHooks(opts: InstallHookOpts): Promise<void> { | ||
| const claudeDir = join(opts.worktree, ".claude"); | ||
| await mkdir(claudeDir, { recursive: true }); | ||
|
|
||
| const settings = { | ||
| hooks: { | ||
| SessionStart: [ | ||
| { | ||
| hooks: [{ type: "command", command: `${opts.hookScriptPath} session.started` }], | ||
| }, | ||
| ], | ||
| }, | ||
| }; | ||
|
|
||
| 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,43 @@ | ||
| // @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. | ||
| */ | ||
| 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: "ignore", | ||
| }); | ||
| await proc.exited; | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
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.