mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 17:46:26 +00:00
a3a5c272a2
Model backend rows gain a harness column (claude | pi). A pi-harness row runs the agent through the pi coding agent instead of the claude binary — pi speaks the OpenAI Completions API natively, so a bare vLLM/llama.cpp/Ollama endpoint needs no LiteLLM/claude-code-router translation proxy, and the loop is far lighter for slow local token throughput. The Claude subscription and existing claude-harness backends are untouched. Parity comes from generated per-agent artifacts under ~/.handler-pi (outside the repo tree, so the clean-tree gate never trips): models.json + settings.json render the row as a pi provider pinned as the default model; a bundled bridge extension (pi_bridge.ts) adapts pi's events to the exact stdin/stdout contract of `python -m handler.hooks` — the Stop/completion gate re-prompts pi with blockers via a follow-up message, git push runs the test/build/approval gates and denies on failure, questions defer through an ask_operator tool into the normal answer/resume flow, and memory recall is injected at session start. The memory tools are registered natively (pi has no MCP), shelling to a new `python -m handler.mcpserver --call <tool>` seam that reuses the MCP server's implementations. Skills reuse the same ~/.claude/skills sync (pi implements the same SKILL.md standard) plus the repo's committed .claude/skills. Sessions are single JSONL files pre-assigned via --session, so cross-worker resume archives/materializes exactly like claude's; the prompt travels on stdin (pi has no -- separator). The supervisor normalizes pi's event stream on the fly: assistant message_end feeds last_output, the final agent_end becomes the run result. The whole chain was validated live against pi 0.84.1 with a stub OpenAI endpoint: memory injection, push-gate denial (including the protected- branch approval gate), stop-gate block loop, and ask_operator pause all ran end to end through the real hooks and DB. Also: harness selector in the dashboard Models form, pi baked into the control image (NodeSource 22 for pi's node >= 22.19 floor), PI_BIN override, docs in docs/local-models.md, fake_pi fixture + 12 tests (361 total green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdGv3u3DfTsP1S188KDhVH
333 lines
9.4 KiB
TypeScript
333 lines
9.4 KiB
TypeScript
/* Typed client for the Handler API + the row shapes it returns (mirrors the FastAPI
|
|
* pydantic schemas in src/handler/api/schemas.py). The browser calls the API same-origin
|
|
* with relative paths; set NEXT_PUBLIC_API_BASE to point `npm run dev` at another origin.
|
|
*
|
|
* NOTE: this file lives under frontend/lib/, now un-ignored in .gitignore so the source
|
|
* ships and the build works from a fresh clone (the built export under
|
|
* src/handler/api/static/ is what the package serves). */
|
|
|
|
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
|
|
|
|
export type CommandStatus = "queued" | "running" | "done" | "failed";
|
|
|
|
export interface Project {
|
|
id: string;
|
|
root_dir: string;
|
|
git_remote?: string | null;
|
|
credential_ref?: string | null;
|
|
created_at: string;
|
|
/* Present on the registration response in git-server mode: the enqueued clone. */
|
|
sync_command_id?: number | null;
|
|
/* Present on the registration response when "Initialize mise" was ticked: the
|
|
* enqueued bootstrap agent that writes + commits + pushes a .mise.toml. */
|
|
mise_init_command_id?: number | null;
|
|
}
|
|
|
|
export interface Agent {
|
|
id: number;
|
|
project_id: string;
|
|
name: string;
|
|
working_dir: string;
|
|
status: string;
|
|
role?: string | null;
|
|
/* Model backend the agent is pinned to (see ClaudeModel); null = the Claude
|
|
* subscription the worker is logged in to. */
|
|
model_id?: number | null;
|
|
/* Latest output snapshot from the worker: the tmux pane tail for legacy agents, the
|
|
* latest assistant text for headless runs. For a crashed agent this is the evidence
|
|
* frame — the last thing the process said. */
|
|
last_output?: string | null;
|
|
output_at?: string | null;
|
|
/* Headless runner: claude session UUID (null = legacy tmux agent) + supervising worker. */
|
|
session_id?: string | null;
|
|
worker_id?: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
/* One persisted stream-json event of a headless run (GET .../events, cursor-paged by id).
|
|
* `type` mirrors the stream (system/assistant/user/result) plus `worker` (runner notices)
|
|
* and `raw` (unparseable line kept verbatim). */
|
|
export interface AgentEvent {
|
|
id: number;
|
|
agent_id: number;
|
|
run_id: number;
|
|
session_id?: string | null;
|
|
seq: number;
|
|
type: string;
|
|
payload?: Record<string, unknown> | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Checkmark {
|
|
agent_id: number;
|
|
checkpoint_at: string;
|
|
status: string;
|
|
where_it_stopped?: string | null;
|
|
next_steps?: string[] | null;
|
|
open_question?: string | null;
|
|
log_entry_id?: number | null;
|
|
tests_status: string;
|
|
tested_at?: string | null;
|
|
build_status: string;
|
|
built_at?: string | null;
|
|
}
|
|
|
|
export interface LogEntry {
|
|
id: number;
|
|
agent_id: number;
|
|
created_at: string;
|
|
session_id?: string | null;
|
|
status: string;
|
|
summary?: string | null;
|
|
decisions?: string | null;
|
|
question?: string | null;
|
|
answer?: string | null;
|
|
visibility: string;
|
|
push_sha?: string | null;
|
|
ci_status: string;
|
|
ci_checked_at?: string | null;
|
|
}
|
|
|
|
export interface Approval {
|
|
id: number;
|
|
project_id: string;
|
|
branch: string;
|
|
approved_sha?: string | null;
|
|
pr_ref?: string | null;
|
|
status: string;
|
|
approved_by_agent_id?: number | null;
|
|
actor?: string | null;
|
|
note?: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Host {
|
|
hostname: string;
|
|
forge_type: string;
|
|
token_env_var?: string | null;
|
|
base_url?: string | null;
|
|
ssh_public_key?: string | null;
|
|
has_token: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Command {
|
|
id: number;
|
|
project_id?: string | null;
|
|
agent_name?: string | null;
|
|
type: string;
|
|
payload?: Record<string, unknown> | null;
|
|
status: CommandStatus;
|
|
result?: Record<string, unknown> | null;
|
|
error?: string | null;
|
|
requested_by?: string | null;
|
|
claimed_by?: string | null;
|
|
created_at: string;
|
|
claimed_at?: string | null;
|
|
finished_at?: string | null;
|
|
}
|
|
|
|
export interface Schedule {
|
|
id: number;
|
|
project_id: string;
|
|
name_prefix: string;
|
|
task: string;
|
|
role?: string | null;
|
|
worktree?: string | null;
|
|
subdir?: string | null;
|
|
/* Model backend every fired run spawns on (see ClaudeModel); null = subscription. */
|
|
model_id?: number | null;
|
|
interval_seconds: number;
|
|
enabled: boolean;
|
|
next_run_at: string;
|
|
last_run_at?: string | null;
|
|
last_command_id?: number | null;
|
|
created_at: string;
|
|
}
|
|
|
|
/* ---- Claude management (the dashboard's Claude page) ---- */
|
|
|
|
export interface ClaudeSkill {
|
|
id: number;
|
|
name: string;
|
|
description?: string | null;
|
|
content: string;
|
|
enabled: boolean;
|
|
/* Relative paths of auxiliary files captured by an install-from-prompt import
|
|
* (references/, scripts/, …); synced alongside SKILL.md, read-only here. */
|
|
files: string[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export type McpTransport = "stdio" | "http" | "sse";
|
|
|
|
export interface ClaudeConnector {
|
|
id: number;
|
|
name: string;
|
|
transport: McpTransport;
|
|
command?: string | null;
|
|
args?: string[] | null;
|
|
env?: Record<string, string> | null;
|
|
url?: string | null;
|
|
headers?: Record<string, string> | null;
|
|
enabled: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ClaudePlugin {
|
|
id: number;
|
|
name: string;
|
|
marketplace: string;
|
|
marketplace_repo: string;
|
|
enabled: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
/* A registered model backend: an Anthropic-API-compatible endpoint (a local model
|
|
* behind LiteLLM / claude-code-router, an LLM gateway) the spawn dropdown offers next
|
|
* to the Claude subscription. The API key is write-only server-side (has_api_key only). */
|
|
export interface ClaudeModel {
|
|
id: number;
|
|
name: string;
|
|
base_url: string;
|
|
model: string;
|
|
small_fast_model?: string | null;
|
|
/* Which agent binary runs against this backend: "claude" (Anthropic-compatible
|
|
endpoint required) or "pi" (bare OpenAI-compatible endpoint, lightweight). */
|
|
harness?: "claude" | "pi";
|
|
env?: Record<string, string> | null;
|
|
enabled: boolean;
|
|
has_api_key: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
/* Stored overrides + the env baseline they merge over at launch (read-only here). */
|
|
export interface ClaudePermissions {
|
|
default_mode?: string | null;
|
|
allow: string[];
|
|
deny: string[];
|
|
ask: string[];
|
|
base_mode: string;
|
|
base_allow: string[];
|
|
}
|
|
|
|
/* ---- agent memory (the Memory page's note graph) ---- */
|
|
|
|
export type NoteKind = "fact" | "decision" | "gotcha" | "runbook";
|
|
|
|
export interface MemoryNote {
|
|
id: number;
|
|
project_id?: string | null; // null = global note
|
|
agent_id?: number | null; // authoring agent; null = operator-authored
|
|
title: string;
|
|
body: string;
|
|
kind: string;
|
|
tags?: string[] | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface MemoryLink {
|
|
id: number;
|
|
src_note_id: number;
|
|
dst_note_id: number;
|
|
relation: string;
|
|
created_by_agent_id?: number | null;
|
|
created_at: string;
|
|
}
|
|
|
|
/* Everything the graph view draws, in one response (GET /memory/graph). */
|
|
export interface MemoryGraph {
|
|
notes: MemoryNote[];
|
|
links: MemoryLink[];
|
|
}
|
|
|
|
export interface SharedContext {
|
|
key: string;
|
|
value: string;
|
|
set_by_agent_id?: number | null;
|
|
updated_at: string;
|
|
}
|
|
|
|
/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
|
|
* quiet while the app re-prompts for a token. */
|
|
export class AuthError extends Error {
|
|
constructor(message = "unauthorized") {
|
|
super(message);
|
|
this.name = "AuthError";
|
|
}
|
|
}
|
|
|
|
/* Any non-2xx (other than 401); carries the HTTP status so callers can branch on 404 etc. */
|
|
export interface ApiError extends Error {
|
|
status: number;
|
|
}
|
|
|
|
interface ApiOptions {
|
|
method?: string;
|
|
body?: unknown;
|
|
}
|
|
|
|
interface TrackOptions {
|
|
attempts?: number;
|
|
intervalMs?: number;
|
|
}
|
|
|
|
export interface ApiClient {
|
|
api: <T>(path: string, opts?: ApiOptions) => Promise<T>;
|
|
/* Poll GET /commands/{id} until it reaches done/failed; null if still running after the
|
|
* budget (worker down or a very slow command). */
|
|
trackCommand: (id: number, opts?: TrackOptions) => Promise<Command | null>;
|
|
}
|
|
|
|
export function createClient(token: string, onUnauthorized: () => void): ApiClient {
|
|
async function api<T>(path: string, opts?: ApiOptions): Promise<T> {
|
|
const hasBody = opts?.body !== undefined && opts?.body !== null;
|
|
const res = await fetch(BASE + path, {
|
|
method: opts?.method ?? (hasBody ? "POST" : "GET"),
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
body: hasBody ? JSON.stringify(opts!.body) : undefined,
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
onUnauthorized();
|
|
throw new AuthError();
|
|
}
|
|
if (!res.ok) {
|
|
let detail: string = res.statusText;
|
|
try {
|
|
const j = await res.json();
|
|
if (j && typeof j.detail !== "undefined") {
|
|
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
|
}
|
|
} catch {
|
|
/* non-JSON error body; keep statusText */
|
|
}
|
|
const err = new Error(detail) as ApiError;
|
|
err.status = res.status;
|
|
throw err;
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
const text = await res.text();
|
|
return (text ? JSON.parse(text) : undefined) as T;
|
|
}
|
|
|
|
async function trackCommand(id: number, opts?: TrackOptions): Promise<Command | null> {
|
|
const attempts = opts?.attempts ?? 60;
|
|
const intervalMs = opts?.intervalMs ?? 500;
|
|
for (let i = 0; i < attempts; i++) {
|
|
const cmd = await api<Command>(`/commands/${id}`);
|
|
if (cmd.status === "done" || cmd.status === "failed") return cmd;
|
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return { api, trackCommand };
|
|
}
|