From 198df1d43440ac05fb87dc1017304610712932a1 Mon Sep 17 00:00:00 2001 From: imsobear Date: Mon, 5 Oct 2026 11:04:17 -0700 Subject: [PATCH 01/30] Add Claude Code as an agent. Driven through claude -p with stream-json, so runs can be watched and the answer comes from the result event. Read only allows just Read, Grep and Glob under dontAsk; workspace write uses acceptEdits plus Bash, since bypassPermissions is refused as root. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 6 +- docs/concepts.md | 6 +- docs/narrative.md | 8 +-- src/agents/claude/events.test.ts | 83 +++++++++++++++++++++++ src/agents/claude/events.ts | 107 +++++++++++++++++++++++++++++ src/agents/claude/manifest.ts | 16 +++++ src/agents/claude/runtime.ts | 112 +++++++++++++++++++++++++++++++ src/agents/manifests.ts | 3 +- src/agents/runtimes.ts | 2 + src/routes/agents/index.tsx | 4 +- 11 files changed, 335 insertions(+), 14 deletions(-) create mode 100644 src/agents/claude/events.test.ts create mode 100644 src/agents/claude/events.ts create mode 100644 src/agents/claude/manifest.ts create mode 100644 src/agents/claude/runtime.ts diff --git a/AGENTS.md b/AGENTS.md index bf2a29a..76c71e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Loopable is your team’s own agent. It runs on a machine the team owns, and the team decides what it can do. -Work comes in from Slack, Lark, GitHub, or a schedule. A loop says what to do. Codex or Cursor Agent does it on a runner, and the answer is written back where it was asked. Every run is recorded. The story is in `docs/narrative.md`. +Work comes in from Slack, Lark, GitHub, or a schedule. A loop says what to do. Codex, Claude Code, or Cursor Agent does it on a runner, and the answer is written back where it was asked. Every run is recorded. The story is in `docs/narrative.md`. ## Stack diff --git a/README.md b/README.md index 3951349..08f74df 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A lot of team work waits on one person: a review request, a question in #oncall, Loopable is a shared agent for small and mid-size teams. - **Shared.** Work comes in from Slack, Lark, GitHub, or a schedule. A loop says what to do. Nobody has to be there. Every run is recorded in the inbox. -- **Yours.** Codex or Cursor Agent runs on a machine the team owns. It reaches what that machine reaches, uses the MCP servers and skills you install, and keeps data in-house. +- **Yours.** Codex, Claude Code, or Cursor Agent runs on a machine the team owns. It reaches what that machine reaches, uses the MCP servers and skills you install, and keeps data in-house. The story is in [docs/narrative.md](docs/narrative.md). The words for the parts — loop, workflow, runner, and the rest — are in [docs/concepts.md](docs/concepts.md). @@ -34,7 +34,7 @@ Three processes. The agent always runs on a Runner, never on the App or Dispatch Slack bot, GitHub, and Lark bot into Loopable. A runner pulls the job. Loopable writes back to the same three.

-Slack bot, GitHub, and Lark bot send work in. Loopable watches, matches a loop, and queues the job. A Runner pulls it, runs Codex or Cursor Agent, and Loopable writes the result back. +Slack bot, GitHub, and Lark bot send work in. Loopable watches, matches a loop, and queues the job. A Runner pulls it, runs Codex, Claude Code, or Cursor Agent, and Loopable writes the result back. ```text Loopable ──HTTP pull── Runner ── Agent CLI @@ -53,7 +53,7 @@ App and Dispatcher share one database and stay on the same host. Only one Dispat The usual setup is one computer that stays on — a Mac in the office is enough. App, Dispatcher, and a Runner all live there. It looks like a spare machine on a desk. For a small team, that is the product. -You need Node 22+ and an agent CLI signed in on that machine (Codex or Cursor Agent). +You need Node 22+ and an agent CLI signed in on that machine (Codex, Claude Code, or Cursor Agent). ```bash npm install -g loopable-cli diff --git a/docs/concepts.md b/docs/concepts.md index bf49d13..21aea5e 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -36,9 +36,9 @@ The first look never acts. Whatever is already waiting when a loop is created is ## What does the work -**Agent.** A coding CLI Loopable knows how to invoke: Codex, Cursor Agent. The list is a catalog in the repo, not discovered from the network. Loopable stores only the choices a person makes: which agent is the default, permission mode, model, timeout. Whether one is installed lives on each runner’s inventory. +**Agent.** A coding CLI Loopable knows how to invoke: Codex, Claude Code, Cursor Agent. The list is a catalog in the repo, not discovered from the network. Loopable stores only the choices a person makes: which agent is the default, permission mode, model, timeout. Whether one is installed lives on each runner’s inventory. -**Agent login.** That CLI’s own login on the runner’s host. It is not a Connection. Connection is GitHub, a Slack bot, a Feishu bot, Gmail, or WeChat. Agent login is Codex or Cursor Agent. They live in different places and must not be merged into one “account.” +**Agent login.** That CLI’s own login on the runner’s host. It is not a Connection. Connection is GitHub, a Slack bot, a Feishu bot, Gmail, or WeChat. Agent login is Codex, Claude Code, or Cursor Agent. They live in different places and must not be merged into one “account.” **Runner.** A process that can run agents. It reports an inventory: which agents are installed and signed in. It heartbeats, pulls a job, runs the agent, and returns output and logs. It does not poll connectors, does not hold Connections, and does not write back to GitHub or Slack. The command is `loopable runner`. The join token lives on the Runners page. @@ -68,7 +68,7 @@ The path does not change with how many runners you have. One runner on the same | | Connection | Runtime auth | Agent login | | --- | --- | --- | --- | -| What | GitHub, Slack bot, Feishu / Lark bot, Gmail, WeChat | git / `gh` on the runner | Codex, Cursor Agent | +| What | GitHub, Slack bot, Feishu / Lark bot, Gmail, WeChat | git / `gh` on the runner | Codex, Claude Code, Cursor Agent | | Where | Loopable’s secret store | That host’s home directory | That host’s home directory | | Who uses it | App and Dispatcher, to read signals and write back | The agent, via git on that host | Only the agent process | diff --git a/docs/narrative.md b/docs/narrative.md index 61d4d0d..4b12dab 100644 --- a/docs/narrative.md +++ b/docs/narrative.md @@ -2,7 +2,7 @@ Loopable is your team’s own agent. It runs on a machine the team owns, and the team decides what it can do. -It is for small and mid-size teams. Work comes in from Slack, Lark, GitHub, or a schedule. Codex or Cursor Agent does it on that machine. The answer goes back where it was asked, and every run is recorded. +It is for small and mid-size teams. Work comes in from Slack, Lark, GitHub, or a schedule. Codex, Claude Code, or Cursor Agent does it on that machine. The answer goes back where it was asked, and every run is recorded. ## The problem @@ -29,7 +29,7 @@ The agent runs on a Runner the team owns. The usual install is one computer that - **Reach.** Whatever that machine can reach, the agent can reach: the internal network, logs, databases, internal services. - **Extend.** Add any MCP server, CLI, or login to an internal service on that machine. No platform decides what is allowed. -- **Choose.** Pick the agent and the model. Codex and Cursor Agent today. +- **Choose.** Pick the agent and the model. Codex, Claude Code, and Cursor Agent today. - **Keep local.** Data and credentials stay on the team’s machines. The agent uses the CLI subscription the team already pays for. ## Safe to leave on @@ -53,12 +53,12 @@ A team that lives only in Slack, uses only one vendor, and needs nothing inside ```text Signal → Loop → Task → Dispatcher watches, matches a loop, queues the job - → Runner pulls the job, runs Codex or Cursor Agent + → Runner pulls the job, runs Codex, Claude Code, or Cursor Agent → Dispatcher writes back ``` The runner claims work over HTTP. Loopable does not push into the runner. A Slack bot, a Lark bot, GitHub, and the clock are ways to create a signal. The path inside does not change. Connections live on Loopable. Agent login lives on the runner. -Shipped today: GitHub, Slack bot, Feishu / Lark bot, Gmail, WeChat, the clock, runners, Codex and Cursor Agent. +Shipped today: GitHub, Slack bot, Feishu / Lark bot, Gmail, WeChat, the clock, runners, Codex, Claude Code, and Cursor Agent. The words for the parts are in [concepts.md](concepts.md). Extra runners are in [deploy.md](deploy.md). diff --git a/src/agents/claude/events.test.ts b/src/agents/claude/events.test.ts new file mode 100644 index 0000000..d535a11 --- /dev/null +++ b/src/agents/claude/events.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { answerFrom, describeEvent } from "./events.ts"; + +/** Shaped like `claude -p --output-format stream-json --verbose`, trimmed to the fields we read. */ +const INIT = `{"type":"system","subtype":"init","cwd":"/tmp/x","session_id":"s1","tools":["Glob","Grep","Read"],"model":"claude-sonnet-5-5","permissionMode":"dontAsk"}`; +const NARRATION = `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Looking at the files now."}]},"session_id":"s1"}`; +const TOOL_USE = `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/x/note.txt"}}]},"session_id":"s1"}`; +const TOOL_RESULT = `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"hello"}]},"session_id":"s1"}`; +const ANSWER = `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"## Review\\n\\nLooks fine."}]},"session_id":"s1"}`; +const RESULT = `{"type":"result","subtype":"success","is_error":false,"duration_ms":20854,"num_turns":2,"result":"## Review\\n\\nLooks fine.","session_id":"s1","usage":{"input_tokens":4,"output_tokens":104}}`; + +/** Captured from a real run on a host that was not signed in. */ +const SIGNED_OUT = [ + `{"type":"system","subtype":"init","cwd":"/tmp/x","session_id":"s2","tools":["Glob","Grep","Read"],"model":"claude-haiku-4-5","permissionMode":"dontAsk"}`, + `{"type":"assistant","message":{"model":"","role":"assistant","content":[{"type":"text","text":"Not logged in · Please run /login"}]},"session_id":"s2","error":"authentication_failed","is_api_error_message":true}`, + `{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"duration_ms":300,"num_turns":1,"result":"Not logged in · Please run /login","session_id":"s2","usage":{"input_tokens":0,"output_tokens":0}}`, +].join("\n"); + +describe("answerFrom", () => { + it("takes the result, which is the last thing said", () => { + const run = [INIT, NARRATION, TOOL_USE, TOOL_RESULT, ANSWER, RESULT].join("\n"); + expect(answerFrom(run)).toEqual({ text: "## Review\n\nLooks fine.", failed: false }); + }); + + it("reports a failed run with the reason", () => { + expect(answerFrom(SIGNED_OUT)).toEqual({ + text: "Not logged in · Please run /login", + failed: true, + }); + }); + + /** + * A stopped run never reaches the result. Half an answer explains a lot; the + * narration from before the tool use would only mislead. + */ + it("keeps only what came after the last tool when the run was cut off", () => { + const cut = [INIT, NARRATION, TOOL_USE, TOOL_RESULT, ANSWER].join("\n"); + expect(answerFrom(cut)).toEqual({ text: "## Review\n\nLooks fine.", failed: false }); + }); + + it("says nothing when there is nothing", () => { + expect(answerFrom("").text).toBe(""); + expect(answerFrom("not json at all").text).toBe(""); + expect(answerFrom([INIT, NARRATION, TOOL_USE].join("\n")).text).toBe(""); + }); +}); + +describe("describeEvent", () => { + it("names the model it started with", () => { + expect(describeEvent(INIT)).toBe("Started with claude-sonnet-5-5."); + }); + + it("reports a tool when it is used", () => { + expect(describeEvent(TOOL_USE)).toBe("Running read /tmp/x/note.txt"); + expect(describeEvent(TOOL_RESULT)).toBeNull(); + }); + + it("drops the run directory from paths inside it", () => { + expect(describeEvent(TOOL_USE, "/tmp/x")).toBe("Running read note.txt"); + expect(describeEvent(TOOL_USE, "/somewhere/else")).toBe("Running read /tmp/x/note.txt"); + }); + + it("stays quiet about speech, which is shown in full elsewhere", () => { + expect(describeEvent(NARRATION)).toBeNull(); + expect(describeEvent(ANSWER)).toBeNull(); + }); + + it("closes with how long it took and what it spent", () => { + expect(describeEvent(RESULT)).toBe("Answered after 21s, 104 tokens out."); + }); + + it("says why it failed", () => { + const failed = SIGNED_OUT.split("\n")[2]!; + expect(describeEvent(failed)).toBe( + "Failed after 0s, 0 tokens out: Not logged in · Please run /login", + ); + }); + + it("passes through anything that is not an event, which is where crashes appear", () => { + expect(describeEvent("Error: command not found")).toBe("Error: command not found"); + expect(describeEvent(" ")).toBeNull(); + }); +}); diff --git a/src/agents/claude/events.ts b/src/agents/claude/events.ts new file mode 100644 index 0000000..86afa7c --- /dev/null +++ b/src/agents/claude/events.ts @@ -0,0 +1,107 @@ +/** + * claude reports as a stream of JSON events in print mode, one line each. Every + * assistant event is a whole message, so unlike cursor-agent there are no + * partial pieces to skip: the run reads as speech, tool uses, and a result. + */ + +type ContentBlock = { + type?: string; + text?: string; + name?: string; + input?: Record; +}; + +type ClaudeEvent = { + type?: string; + subtype?: string; + model?: string; + message?: { content?: ContentBlock[] }; + result?: string; + is_error?: boolean; + duration_ms?: number; + usage?: { output_tokens?: number }; +}; + +function parse(line: string): ClaudeEvent | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return null; + try { + return JSON.parse(trimmed) as ClaudeEvent; + } catch { + return null; + } +} + +function blocks(event: ClaudeEvent | null): ContentBlock[] { + return event?.type === "assistant" ? (event.message?.content ?? []) : []; +} + +/** `Read` with a file_path becomes `read `. */ +function describeTool(block: ContentBlock, cwd?: string): string { + const verb = (block.name ?? "a tool").toLowerCase(); + const input = block.input ?? {}; + const subject = input.file_path ?? input.path ?? input.command ?? input.pattern ?? input.url ?? input.query; + if (typeof subject !== "string") return verb; + // Everything the agent touches lives in the run directory, so its name is + // the only interesting part of the path. + const short = cwd && subject.startsWith(`${cwd}/`) ? subject.slice(cwd.length + 1) : subject; + return `${verb} ${short}`; +} + +/** + * One readable line for the log, or nothing when the event says nothing a + * person would want to read. Speech is left out: the answer is shown in full + * elsewhere, and the narration in between is what the tool lines already say. + */ +export function describeEvent(line: string, cwd?: string): string | null { + const event = parse(line); + if (!event) return line.trim() === "" ? null : line; + + switch (event.type) { + case "system": + if (event.subtype !== "init") return null; + return event.model ? `Started with ${event.model}.` : "Started."; + case "assistant": { + const tools = blocks(event) + .filter((block) => block.type === "tool_use") + .map((block) => `Running ${describeTool(block, cwd)}`); + return tools.length > 0 ? tools.join("\n") : null; + } + case "result": { + const seconds = event.duration_ms !== undefined ? (event.duration_ms / 1000).toFixed(0) : "?"; + const tokens = event.usage?.output_tokens; + const spent = tokens === undefined ? "" : `, ${tokens} tokens out`; + return event.is_error + ? `Failed after ${seconds}s${spent}: ${event.result ?? event.subtype ?? "unknown error"}` + : `Answered after ${seconds}s${spent}.`; + } + default: + return null; + } +} + +/** + * The answer, which is the agent's last message and not everything it said. + * + * The result event carries exactly that, so it wins when present. A stopped + * run never gets one; then the text after the last tool use is the block it + * was in the middle of, and earlier text is it thinking out loud. + */ +export function answerFrom(stdout: string): { text: string; failed: boolean } { + const events = stdout.split("\n").map(parse); + const result = events.filter((event) => event?.type === "result").at(-1); + const failed = result?.is_error === true; + if (typeof result?.result === "string" && result.result.trim() !== "") { + return { text: result.result.trim(), failed }; + } + + const said = events.flatMap(blocks); + const lastTool = said.findLastIndex((block) => block.type === "tool_use"); + const text = said + .slice(lastTool + 1) + .filter((block) => block.type === "text") + .map((block) => block.text ?? "") + .join("\n") + .trim(); + return { text, failed }; +} diff --git a/src/agents/claude/manifest.ts b/src/agents/claude/manifest.ts new file mode 100644 index 0000000..9655d2c --- /dev/null +++ b/src/agents/claude/manifest.ts @@ -0,0 +1,16 @@ +import { defineAgentManifest } from "../define.ts"; + +export const claudeManifest = defineAgentManifest({ + id: "claude", + name: "Claude Code", + tagline: "Anthropic's coding agent, driven through its print mode.", + docsUrl: "https://docs.claude.com/en/docs/claude-code/cli-reference", + icon: "Sparkles", + accent: "bg-[#d97757] text-white", + binaries: ["claude"], + installHint: "Install with curl -fsSL https://claude.ai/install.sh | bash, then run claude auth login.", + permissionModes: ["read_only", "workspace_write"], + supportsModel: true, + modelPlaceholder: "e.g. sonnet or opus; leave empty for the default", + defaults: { permissionMode: "read_only", model: null, timeoutMs: 300_000 }, +}); diff --git a/src/agents/claude/runtime.ts b/src/agents/claude/runtime.ts new file mode 100644 index 0000000..062aa65 --- /dev/null +++ b/src/agents/claude/runtime.ts @@ -0,0 +1,112 @@ +import { defineAgentRuntime } from "../define.ts"; +import { firstLine, probe, resolveBinary } from "../discover.ts"; +import { runProcess } from "../process.ts"; +import type { AgentInvocation, AgentRunInput } from "../types.ts"; +import { answerFrom, describeEvent } from "./events.ts"; +import { claudeManifest } from "./manifest.ts"; + +async function binary(): Promise { + const found = await resolveBinary(claudeManifest.binaries); + if (!found) throw new Error("The claude binary was not found on this host."); + return found; +} + +function args(input: AgentRunInput): string[] { + const list = [ + "-p", + // A stream of events rather than one block at the end, so a run can be + // watched. Print mode refuses stream-json without --verbose. + "--output-format", + "stream-json", + "--verbose", + // Session files would outlive a run that is meant to leave nothing behind. + "--no-session-persistence", + ]; + if (input.settings.permissionMode === "read_only") { + // Only tools that look. dontAsk denies anything else instead of waiting + // for an answer nobody is there to give. + list.push("--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob"); + } else { + // Edits inside the workspace and any shell command. bypassPermissions + // would be the closer match, but claude refuses it when run as root, + // which is how the container runs. + list.push("--permission-mode", "acceptEdits", "--allowedTools", "Bash"); + } + if (input.settings.model) list.push("--model", input.settings.model); + // The prompt follows a variadic option, so it has to be marked as the + // positional argument or --allowedTools would swallow it. + list.push("--", input.prompt); + return list; +} + +export const claudeRuntime = defineAgentRuntime({ + async detect() { + const found = await resolveBinary(claudeManifest.binaries); + if (!found) return { installed: false }; + const version = await probe(found, ["--version"]); + return { installed: true, binaryPath: found, version: firstLine(version.text) }; + }, + + async authStatus() { + const found = await resolveBinary(claudeManifest.binaries); + if (!found) return { signedIn: false, detail: "Not installed." }; + // Exits non-zero when signed out, and prints JSON either way. + const result = await probe(found, ["auth", "status", "--json"]); + try { + const status = JSON.parse(result.text) as { loggedIn?: boolean; authMethod?: string }; + return status.loggedIn + ? { signedIn: true, detail: `Signed in (${status.authMethod ?? "unknown method"}).` } + : { signedIn: false, detail: "Run claude auth login." }; + } catch { + return { + signedIn: result.ok, + detail: firstLine(result.text) || (result.ok ? "Signed in." : "Run claude auth login."), + }; + } + }, + + invocation(input): AgentInvocation { + return { bin: claudeManifest.binaries[0]!, args: args(input) }; + }, + + async run(input) { + const bin = await binary(); + const argv = args(input); + const outcome = await runProcess({ + bin, + args: argv, + cwd: input.cwd, + timeoutMs: input.settings.timeoutMs, + signal: input.signal, + logPath: input.logFile, + logLine: (line) => describeEvent(line, input.cwd), + }); + const command = `${bin} ${argv.join(" ")}`; + const answer = answerFrom(outcome.stdout); + + if (outcome.aborted) { + return { ok: false, aborted: true, output: answer.text, durationMs: outcome.durationMs, command }; + } + if (outcome.timedOut) { + return { + ok: false, + output: answer.text, + detail: `Claude Code did not finish within ${Math.round(input.settings.timeoutMs / 1000)}s.`, + durationMs: outcome.durationMs, + command, + }; + } + return { + ok: outcome.code === 0 && !answer.failed, + output: answer.text, + detail: + outcome.code === 0 && !answer.failed + ? undefined + : answer.failed + ? answer.text || "The agent reported an error." + : outcome.stderr || `Exited with code ${outcome.code}.`, + durationMs: outcome.durationMs, + command, + }; + }, +}); diff --git a/src/agents/manifests.ts b/src/agents/manifests.ts index 2a84b36..57dedfc 100644 --- a/src/agents/manifests.ts +++ b/src/agents/manifests.ts @@ -1,9 +1,10 @@ +import { claudeManifest } from "./claude/manifest.ts"; import { codexManifest } from "./codex/manifest.ts"; import { cursorAgentManifest } from "./cursor-agent/manifest.ts"; import type { AgentId, AgentManifest } from "./types.ts"; /** Client-safe registry, listing only agents whose invocation we have verified. */ -export const AGENT_MANIFESTS: AgentManifest[] = [codexManifest, cursorAgentManifest]; +export const AGENT_MANIFESTS: AgentManifest[] = [codexManifest, claudeManifest, cursorAgentManifest]; export function agentManifest(id: AgentId): AgentManifest | undefined { return AGENT_MANIFESTS.find((manifest) => manifest.id === id); diff --git a/src/agents/runtimes.ts b/src/agents/runtimes.ts index e82e581..d5c4054 100644 --- a/src/agents/runtimes.ts +++ b/src/agents/runtimes.ts @@ -1,3 +1,4 @@ +import { claudeRuntime } from "./claude/runtime.ts"; import { codexRuntime } from "./codex/runtime.ts"; import { cursorAgentRuntime } from "./cursor-agent/runtime.ts"; import type { AgentId, AgentRuntime } from "./types.ts"; @@ -5,6 +6,7 @@ import type { AgentId, AgentRuntime } from "./types.ts"; /** Server-only registry. Never import this from a component. */ const RUNTIMES: Record = { codex: codexRuntime, + claude: claudeRuntime, "cursor-agent": cursorAgentRuntime, }; diff --git a/src/routes/agents/index.tsx b/src/routes/agents/index.tsx index fb85345..0ad44e0 100644 --- a/src/routes/agents/index.tsx +++ b/src/routes/agents/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"; import { useState } from "react"; import { toast } from "sonner"; -import { Bot, ChevronDown, MousePointerClick, PlayCircle, Terminal, type LucideIcon } from "lucide-react"; +import { Bot, ChevronDown, MousePointerClick, PlayCircle, Sparkles, Terminal, type LucideIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -26,7 +26,7 @@ import { saveAgent, } from "@/server/functions/agents.ts"; -const ICONS: Record = { Terminal, MousePointerClick, Bot }; +const ICONS: Record = { Terminal, MousePointerClick, Sparkles, Bot }; const MODE_LABELS: Record = { read_only: "Read only", From 98b8604f8d6e43902c6121b06c8616d6a0b2497a Mon Sep 17 00:00:00 2001 From: imsobear Date: Mon, 5 Oct 2026 11:34:40 -0700 Subject: [PATCH 02/30] Reload the dev runner when its code changes. The runner reports the agents it has loaded, so one started before an agent was added kept saying that agent was on no runner. Co-Authored-By: Claude Opus 5.5 --- scripts/dev.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/dev.ts b/scripts/dev.ts index 2f070f0..d28ffcb 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -70,7 +70,9 @@ async function waitForApp(): Promise { const { getJoinToken } = await import("../src/server/runners.ts"); await waitForApp(); const joinToken = await getJoinToken(); -runner = run(node, ["--experimental-strip-types", join(root, "src/runner/main.ts")], { +// --watch for the same reason: a newly added agent is only detected by a +// runner that has loaded it. It rejoins by hostname, so it stays one runner. +runner = run(node, ["--watch", "--experimental-strip-types", join(root, "src/runner/main.ts")], { LOOPABLE_DEV: "1", LOOPABLE_URL: appUrl, LOOPABLE_RUNNER_TOKEN: joinToken, From 2e7eafee5a75ef34805668877d2577991ce7e600 Mon Sep 17 00:00:00 2001 From: imsobear Date: Mon, 5 Oct 2026 11:41:56 -0700 Subject: [PATCH 03/30] Clear the dev server warnings. Server functions use validator() instead of the deprecated inputValidator(), buttons rendered as links say they are not native buttons, and the agents page no longer exports its component, so the route can be code-split. Co-Authored-By: Claude Opus 5.5 --- src/routes/agents/index.tsx | 2 +- src/routes/connectors/$connectorId.tsx | 3 ++- src/routes/connectors/index.tsx | 2 +- src/routes/loops/index.tsx | 4 ++-- src/server/functions/agents.ts | 6 +++--- src/server/functions/connectors.ts | 12 ++++++------ src/server/functions/loops.ts | 16 ++++++++-------- src/server/functions/runners.ts | 2 +- src/server/functions/tasks.ts | 14 +++++++------- 9 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/routes/agents/index.tsx b/src/routes/agents/index.tsx index 0ad44e0..c63dcc3 100644 --- a/src/routes/agents/index.tsx +++ b/src/routes/agents/index.tsx @@ -38,7 +38,7 @@ export const Route = createFileRoute("/agents/")({ component: AgentsPage, }); -export function AgentsPage() { +function AgentsPage() { const { agents } = Route.useLoaderData(); useLiveRefresh(); diff --git a/src/routes/connectors/$connectorId.tsx b/src/routes/connectors/$connectorId.tsx index 2fe508f..a95ef00 100644 --- a/src/routes/connectors/$connectorId.tsx +++ b/src/routes/connectors/$connectorId.tsx @@ -71,7 +71,7 @@ function ConnectorDetailPage() { {pasting ? "Cancel" : "Add bot"} ) : ( - ) @@ -271,6 +271,7 @@ function ConnectionCard({ {broken ? ( diff --git a/src/routes/loops/index.tsx b/src/routes/loops/index.tsx index 4238aac..4bbf720 100644 --- a/src/routes/loops/index.tsx +++ b/src/routes/loops/index.tsx @@ -34,7 +34,7 @@ function LoopsPage() { matches something is the one that runs, so order matters.

- @@ -48,7 +48,7 @@ function LoopsPage() {

No loops yet, so nothing is being watched for.

- diff --git a/src/server/functions/agents.ts b/src/server/functions/agents.ts index ceb3605..2e1f1c5 100644 --- a/src/server/functions/agents.ts +++ b/src/server/functions/agents.ts @@ -38,7 +38,7 @@ export const getAgentsPage = createServerFn({ method: "GET" }).handler(async () })); export const saveAgent = createServerFn({ method: "POST" }) - .inputValidator( + .validator( (data: { agentId: string; permissionMode: PermissionMode; @@ -56,14 +56,14 @@ export const saveAgent = createServerFn({ method: "POST" }) }); export const chooseDefaultAgent = createServerFn({ method: "POST" }) - .inputValidator((data: { agentId: string }) => data) + .validator((data: { agentId: string }) => data) .handler(async ({ data }) => { await setDefaultAgent(data.agentId); return await withRunners(await listAgents()); }); export const runAgentTest = createServerFn({ method: "POST" }) - .inputValidator((data: { agentId: string }) => data) + .validator((data: { agentId: string }) => data) .handler(async ({ data }) => { const result = await testAgent(data.agentId); return { diff --git a/src/server/functions/connectors.ts b/src/server/functions/connectors.ts index e6774cf..c5e39ee 100644 --- a/src/server/functions/connectors.ts +++ b/src/server/functions/connectors.ts @@ -54,7 +54,7 @@ export const getConnectorOverview = createServerFn({ method: "GET" }).handler( * provider's login works. */ export const beginQrLogin = createServerFn({ method: "POST" }) - .inputValidator((data: { connectorId: string }) => data) + .validator((data: { connectorId: string }) => data) .handler(async ({ data }): Promise => { const runtime = connectorRuntime(data.connectorId); if (!runtime.auth.startQrLogin) { @@ -73,7 +73,7 @@ export const beginQrLogin = createServerFn({ method: "POST" }) * confirmed login is saved here and the page is told only that it worked. */ export const continueQrLogin = createServerFn({ method: "POST" }) - .inputValidator((data: { connectorId: string; attempt: JsonValue }) => data) + .validator((data: { connectorId: string; attempt: JsonValue }) => data) .handler(async ({ data }): Promise => { const runtime = connectorRuntime(data.connectorId); if (!runtime.auth.pollQrLogin) { @@ -93,7 +93,7 @@ export const continueQrLogin = createServerFn({ method: "POST" }) * label, never the token back. */ export const connectWithToken = createServerFn({ method: "POST" }) - .inputValidator((data: { connectorId: string; fields: Record }) => data) + .validator((data: { connectorId: string; fields: Record }) => data) .handler(async ({ data }) => { const runtime = connectorRuntime(data.connectorId); if (!runtime.auth.connectWithFields) { @@ -105,16 +105,16 @@ export const connectWithToken = createServerFn({ method: "POST" }) }); export const disconnectConnection = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => { await removeConnection(data.id); return { ok: true }; }); export const verifyConnection = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => await checkConnection(data.id)); export const saveConnectionSettings = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string; settings: ConnectionSettings }) => data) + .validator((data: { id: string; settings: ConnectionSettings }) => data) .handler(async ({ data }) => await updateConnectionSettings(data.id, data.settings)); diff --git a/src/server/functions/loops.ts b/src/server/functions/loops.ts index e730b70..4807969 100644 --- a/src/server/functions/loops.ts +++ b/src/server/functions/loops.ts @@ -27,39 +27,39 @@ export const getLoopsPage = createServerFn({ method: "GET" }).handler(async () = export const getLoopReadiness = createServerFn({ method: "GET" }).handler(async () => await loopReadiness()); export const getLoopById = createServerFn({ method: "GET" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => await getLoop(data.id)); export const saveLoop = createServerFn({ method: "POST" }) - .inputValidator(edit) + .validator(edit) .handler(async ({ data }) => { const { id, ...values } = data; return id ? await updateLoop(id, values) : await createLoop(values); }); export const toggleLoop = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string; enabled: boolean }) => data) + .validator((data: { id: string; enabled: boolean }) => data) .handler(async ({ data }) => await setLoopEnabled(data.id, data.enabled)); export const removeLoop = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => { await deleteLoop(data.id); return await listLoops(); }); export const reorderLoop = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string; direction: "up" | "down" }) => data) + .validator((data: { id: string; direction: "up" | "down" }) => data) .handler(async ({ data }) => await moveLoop(data.id, data.direction)); export const getLoopPollState = createServerFn({ method: "GET" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => await loopPollState(data.id)); export const runLoopBacklog = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => ({ queued: await runBacklog(data.id) })); export const lookLoopNow = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => await lookNow(data.id)); diff --git a/src/server/functions/runners.ts b/src/server/functions/runners.ts index 6c69c8b..7adc593 100644 --- a/src/server/functions/runners.ts +++ b/src/server/functions/runners.ts @@ -13,7 +13,7 @@ export const rotateRunnerJoinToken = createServerFn({ method: "POST" }).handler( ); export const removeRunner = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => { await forgetRunner(data.id); return await listRunners(); diff --git a/src/server/functions/tasks.ts b/src/server/functions/tasks.ts index 228e797..544b79d 100644 --- a/src/server/functions/tasks.ts +++ b/src/server/functions/tasks.ts @@ -10,27 +10,27 @@ export const getInbox = createServerFn({ method: "GET" }).handler(async () => ({ })); export const getLoopTasks = createServerFn({ method: "GET" }) - .inputValidator((data: { loopId: string }) => data) + .validator((data: { loopId: string }) => data) .handler(async ({ data }) => await listTasks({ loopId: data.loopId })); export const getTaskById = createServerFn({ method: "GET" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => await getTask(data.id)); export const getTaskLog = createServerFn({ method: "GET" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => await readTaskLog(data.id)); export const runPrompt = createServerFn({ method: "POST" }) - .inputValidator((data: { prompt: string; agentId: string }) => data) + .validator((data: { prompt: string; agentId: string }) => data) .handler(async ({ data }) => await enqueuePrompt(data)); export const runLoopNow = createServerFn({ method: "POST" }) - .inputValidator((data: { loopId: string; url: string; dryRun: boolean }) => data) + .validator((data: { loopId: string; url: string; dryRun: boolean }) => data) .handler(async ({ data }) => await enqueueTask(data)); export const stopTask = createServerFn({ method: "POST" }) - .inputValidator((data: { id: string }) => data) + .validator((data: { id: string }) => data) .handler(async ({ data }) => await requestCancel(data.id)); /** Whether the dispatcher is up, and how hard it is allowed to pull. */ @@ -41,5 +41,5 @@ export const getDispatcherState = createServerFn({ method: "GET" }).handler(asyn })); export const pauseDispatcher = createServerFn({ method: "POST" }) - .inputValidator((data: { paused: boolean }) => data) + .validator((data: { paused: boolean }) => data) .handler(async ({ data }) => await setDispatcherPaused(data.paused)); From ee63b01be08d88c2d2a726eb29b45f932fefa6bc Mon Sep 17 00:00:00 2001 From: imsobear Date: Mon, 5 Oct 2026 11:41:56 -0700 Subject: [PATCH 04/30] Refuse server function calls from other sites. Only the app's own pages call server functions, so cross-site requests now get a 403. The /api routes runners use are not affected. Co-Authored-By: Claude Opus 5.5 --- src/start.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/start.ts b/src/start.ts index 8945a22..82f8607 100644 --- a/src/start.ts +++ b/src/start.ts @@ -6,7 +6,7 @@ import { readAppToken, } from "#/server/access.ts"; import { migrateIfNeeded } from "#/server/db/client.ts"; -import { createMiddleware, createStart } from "@tanstack/react-start"; +import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start"; let migrated: Promise | null = null; function ensureMigrated() { @@ -32,6 +32,14 @@ const appAccess = createMiddleware({ type: "request" }).server(async ({ next, re return result; }); +/** + * Server functions are only ever called by the app's own pages, so a request + * from another site is refused. Runners use the /api routes, which this skips. + */ +const sameOrigin = createCsrfMiddleware({ + filter: (ctx) => ctx.handlerType === "serverFn", +}); + export const startInstance = createStart(() => ({ - requestMiddleware: [appAccess], + requestMiddleware: [appAccess, sameOrigin], })); From 9fdd0ad44f57e4d2cce8374ece98572a716a7447 Mon Sep 17 00:00:00 2001 From: imsobear Date: Mon, 5 Oct 2026 11:45:00 -0700 Subject: [PATCH 05/30] Say plainly that GitHub is connected as the team's bot account. The Connection was always meant to be a shared bot, but the copy read as a personal login: workflows said "assigned to me" and nothing said which account to sign in as. The connector page now says so before connecting, the workflows name the bot, and the docs explain the setup and why it is not a GitHub App. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++---- docs/concepts.md | 6 ++--- docs/connectors.md | 20 +++++++++++++++++ docs/deploy.md | 2 +- docs/narrative.md | 1 + site/index.html | 6 ++--- src/components/loop-form.tsx | 4 ++-- src/connectors/github/manifest.ts | 31 +++++++++++++++----------- src/connectors/types.ts | 5 +++++ src/routes/connectors/$connectorId.tsx | 10 +++++++++ 10 files changed, 67 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 08f74df..7ab6711 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ The story is in [docs/narrative.md](docs/narrative.md). The words for the parts | Comes in from | Loop | Writes back | | --- | --- | --- | -| GitHub | Review pull requests I am asked to review | A review, as a comment | -| GitHub | Plan issues assigned to me | A comment with a plan | -| GitHub | Implement issues assigned to me | A draft pull request | +| GitHub | Review pull requests the bot is asked to review | A review, as a comment | +| GitHub | Plan issues assigned to the bot | A comment with a plan | +| GitHub | Implement issues assigned to the bot | A draft pull request | | Slack bot | Do what I ask the bot (@mention or DM) | A reply in the thread | | Feishu / Lark bot | Do what I ask the bot (@mention in a group) | A reply in the thread | | Schedule | Do something on a schedule | The log, or a Slack or Lark channel | @@ -60,7 +60,7 @@ npm install -g loopable-cli loopable start # App + Dispatcher, bound on all interfaces ``` -On the same computer, open `http://127.0.0.1:4321`. Connect GitHub from that address. Write a loop. On Runners, copy the join command and start a runner (this machine is fine): +On the same computer, open `http://127.0.0.1:4321`. Connect GitHub from that address, signed in as your team's GitHub bot account rather than your own (see [GitHub: a shared bot account](docs/connectors.md#github-a-shared-bot-account)). Write a loop. On Runners, copy the join command and start a runner (this machine is fine): ```bash loopable runner --url http://:4321 --token diff --git a/docs/concepts.md b/docs/concepts.md index 21aea5e..befa1b4 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -18,9 +18,9 @@ There is no second path where “the local dispatcher runs the agent.” The age **Connector.** A kind of service: GitHub, Slack bot, Feishu / Lark bot, Gmail, WeChat, the clock. It declares what it can watch and what it can write. It is not an account. -**Connection.** One credential of a connector — a GitHub login, a Slack bot, a Feishu bot, a Gmail inbox. Credentials live in Loopable. Agents never see them. +**Connection.** One credential of a connector — a GitHub bot account, a Slack bot, a Feishu bot, a Gmail inbox. Credentials live in Loopable. Agents never see them. -**Workflow.** A whole job a connector already knows how to do, named the way a person would name it: “Review pull requests I am asked to review.” It owns what to watch for, what to ask the agent, and where the answer is written. A loop is one instance of a workflow with its knobs set. +**Workflow.** A whole job a connector already knows how to do, named the way a person would name it: “Review pull requests the bot is asked to review.” It owns what to watch for, what to ask the agent, and where the answer is written. A loop is one instance of a workflow with its knobs set. A workflow is not a loop template. Creating a loop copies the prompt onto the loop, and the loop owns that copy from then on. The loop still points at the workflow for the rest: the query, how the answer is parsed, whether the agent gets a checkout. If a connector stops offering a workflow, existing loops remain readable and deletable but not runnable. @@ -72,7 +72,7 @@ The path does not change with how many runners you have. One runner on the same | Where | Loopable’s secret store | That host’s home directory | That host’s home directory | | Who uses it | App and Dispatcher, to read signals and write back | The agent, via git on that host | Only the agent process | -They stay apart even when two of them are GitHub. The Connection is the bot that watches and writes through the API. Runtime auth is git on that host, used by the agent when the prompt asks it to clone. Loopable does not copy the Connection token onto a runner or into an agent. +They stay apart even when two of them are GitHub. The Connection is the team's bot account, which watches and writes through the API. Runtime auth is git on that host, used by the agent when the prompt asks it to clone. Sign that git in as the same bot account, so a branch and its pull request come from one name. Loopable does not copy the Connection token onto a runner or into an agent. The agent’s environment never contains Loopable’s service credentials. When a job needs a repository, the prompt tells the agent to clone it. That uses the machine’s git, not the Connection. diff --git a/docs/connectors.md b/docs/connectors.md index b3c17b3..bbd6b62 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -11,6 +11,26 @@ Each connector is a folder under `src/connectors/`. It exports two halves, and t The pages render entirely from manifests. Adding a connector means adding a folder and one line in `src/connectors/manifests.ts` and `src/connectors/runtimes.ts`. No page changes. The contract lives in `src/connectors/types.ts`. +## GitHub: a shared bot account + +Connect GitHub as a bot account the team owns, such as `acme-loopable`, not as a person. Loopable is the team's agent, and on GitHub the bot account is that agent: + +- **People send it work the normal way.** Request a review from it, or assign it an issue. The workflows watch `review-requested:@me` and `assignee:@me`, and `@me` is the connected account. On a personal account they would pick up that person's own reviews and issues. +- **What it writes appears under its name.** Reviews, comments, and draft pull requests come from the bot, not from a teammate who did not write them. +- **Its access is the team's choice.** OAuth asks for the `repo` scope, but the bot can only reach repositories it has been added to. Add it to the repositories, or to a team that holds them, and nothing else. +- **It does not leave with anyone.** A personal connection stops working when that person leaves or loses access. + +To set it up: + +1. Create a GitHub account for the bot, with an email address the team controls. Store its password and 2FA in the team's password manager, held by an admin. +2. Add it to the repositories Loopable should work in, with write access. Write access is what lets it review, comment, and open pull requests. On paid org plans the bot takes a seat. +3. Sign in to GitHub as the bot, then click Connect in Loopable. GitHub authorizes whichever account the browser is signed in as, so a private window that holds only the bot's session is the easiest way. +4. Sign the runner's git and `gh` in as the same bot. The agent clones and pushes with them, so the branch and the pull request then come from one name. + +Loopable does not check that the account is a bot. It works with a personal account too, but the workflows will act on that person's reviews and issues and write as them. + +A GitHub App was considered and set aside for now. An App cannot be requested as a reviewer or assigned an issue, so the workflows would need labels or mentions instead. Its installation tokens also expire after an hour, which does not suit the runner's git. + ## GitHub OAuth End users never register an OAuth app. They click Connect. Loopable ships one GitHub app (and one Google app) whose callback is loopback: diff --git a/docs/deploy.md b/docs/deploy.md index b661bab..0a12679 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -49,4 +49,4 @@ loopable runner --url http://192.168.1.10:4321 --token Open the UI as `http://192.168.1.10:4321/?token=` to copy the join command. Connect GitHub or Gmail from `http://127.0.0.1:4321` on this laptop. A Slack bot or Feishu / Lark bot is a pasted app credential, so it can be added from the LAN UI; it is not a Slack or Feishu login. -Each runner needs the agent CLI signed in, and git / `gh` if jobs clone. Google will not accept a private IP as an OAuth redirect, so Gmail login from another machine on the LAN will not work. GitHub is the same unless you click Connect on this laptop. +Each runner needs the agent CLI signed in, and git / `gh` if jobs clone. Sign git in as the team's GitHub bot account, the same one connected in Loopable. Google will not accept a private IP as an OAuth redirect, so Gmail login from another machine on the LAN will not work. GitHub is the same unless you click Connect on this laptop. diff --git a/docs/narrative.md b/docs/narrative.md index 4b12dab..36a554d 100644 --- a/docs/narrative.md +++ b/docs/narrative.md @@ -38,6 +38,7 @@ An agent that runs with nobody watching has to be boring by default. - Agents start read-only in your folders. GitHub jobs work in a fresh checkout, never in someone’s working copy. - Service credentials stay in Loopable. The agent never sees them. +- GitHub is connected as a shared bot account the team owns, never someone’s own login. Its access is the repositories the team adds it to. - A review is posted as a comment, never an approval. Code goes into a draft pull request, never a merge. - A new loop records what is already waiting. It does not run it. - Loopable needs no public URL. It polls Slack, Lark, and GitHub. Nothing on the internet has to reach the office Mac. diff --git a/site/index.html b/site/index.html index 36fa8f7..0e7493f 100644 --- a/site/index.html +++ b/site/index.html @@ -243,17 +243,17 @@

Loops you can turn on now.

  • GitHub - Review pull requests I am asked to review + Review pull requests the bot is asked to review A review, as a comment
  • GitHub - Plan issues assigned to me + Plan issues assigned to the bot A comment with a plan
  • GitHub - Implement issues assigned to me + Implement issues assigned to the bot A draft pull request
  • diff --git a/src/components/loop-form.tsx b/src/components/loop-form.tsx index 39eefda..075ad2b 100644 --- a/src/components/loop-form.tsx +++ b/src/components/loop-form.tsx @@ -204,7 +204,7 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { workflow.settings.length > 0 ? ", before anything below narrows it further." : "." - }` + }${workflow.watches.includes("@me") ? " @me is the connected account, not you." : ""}` : `${connector?.name ?? loop.connectorId} sends this over as it happens, and Loopable picks it up within a couple of minutes.` } /> @@ -220,7 +220,7 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { } help={ workflow.runsIn === "checkout" - ? "The agent clones the repository with that machine's git. Loopable's GitHub account is not used to clone, and is only used afterwards to write the review or open the pull request." + ? "The agent clones the repository with that machine's git. Loopable's GitHub bot account is not used to clone, and is only used afterwards to write the review or open the pull request. Sign that machine's git in as the same bot, so the branch and the pull request come from one name." : workflow.runsIn === "folder" ? "This job reads real code to do its work, so it runs in a real checkout. It does not write there: anything it changed would be sitting in your working copy." : "The agent gets the files it was given and nothing else." diff --git a/src/connectors/github/manifest.ts b/src/connectors/github/manifest.ts index 0473a33..43232de 100644 --- a/src/connectors/github/manifest.ts +++ b/src/connectors/github/manifest.ts @@ -106,7 +106,7 @@ const IMPLEMENT_PROMPT = [ export const githubManifest = defineManifest({ id: "github", name: "GitHub", - tagline: "Pick up review requests and assigned issues.", + tagline: "Pick up reviews and issues sent to your team's GitHub bot account.", docsUrl: "https://docs.github.com/rest", icon: "Github", accent: "bg-neutral-900 text-white", @@ -114,15 +114,19 @@ export const githubManifest = defineManifest({ kind: "oauth_redirect", scopes: GITHUB_SCOPES, needsAppRegistration: true, + // The Connection is the team's agent on GitHub, not anyone's own login: + // whatever it writes appears under its name, and the work it picks up is + // whatever the team sends to it. + note: "Sign in as a shared bot account for your team, not your own. Reviews, comments and pull requests appear under its name, and it sees only the repositories you add it to. Sign in to GitHub as the bot first; GitHub authorizes whichever account the browser is signed in as.", }, workflows: [ { id: "github.review_requested", - name: "Review pull requests I am asked to review", - summary: "Reads the change and posts a review whenever someone asks for yours.", + name: "Review pull requests the bot is asked to review", + summary: "Reads the change and posts a review whenever someone requests one from the bot account.", // Team requests matter more than they sound: in most repositories with a // CODEOWNERS file, review arrives addressed to a team rather than a person. - trigger: "your review is requested, either directly or through a team you belong to", + trigger: "someone requests a review from the bot account, directly or through a team it belongs to", // review-requested covers teams; user-review-requested would not. watches: "is:open is:pr review-requested:@me", writes: "a review on the pull request, as a comment rather than an approval", @@ -136,9 +140,9 @@ export const githubManifest = defineManifest({ }, { id: "github.issue_assigned", - name: "Plan issues assigned to me", - summary: "Posts a short implementation plan when an issue lands on you.", - trigger: "an issue is assigned to you", + name: "Plan issues assigned to the bot", + summary: "Posts a short implementation plan when an issue is assigned to the bot account.", + trigger: "an issue is assigned to the bot account", // is:issue matters: without it this would pick up your own pull requests. watches: "is:open is:issue assignee:@me", writes: "a comment on the issue", @@ -151,13 +155,13 @@ export const githubManifest = defineManifest({ }, { id: "github.issue_implement", - name: "Implement issues assigned to me", + name: "Implement issues assigned to the bot", summary: "Writes the change and opens a draft pull request.", - trigger: "an issue is assigned to you", + trigger: "an issue is assigned to the bot account", watches: "is:open is:issue assignee:@me", writes: "a draft pull request", // The only workflow that writes code, and so the only one whose agent - // gets somewhere to write. Kept apart from "Plan issues assigned to me" + // gets somewhere to write. Kept apart from "Plan issues assigned to the bot" // rather than replacing it: asking for a plan and asking for the change // are different jobs, and which one an issue deserves is a judgement // about the issue. @@ -204,9 +208,10 @@ export const githubManifest = defineManifest({ // Nothing to configure per account: what the account can see is what GitHub // decides, and every narrowing choice belongs to a loop. settings: [], - // The redirect authorizes whichever account the browser is already signed in - // as, so a second account is out of reach without signing out of GitHub - // first. Connections are still rows, so this is a product decision only. + // One bot account per team. The redirect also authorizes whichever account + // the browser is already signed in as, so a second one is out of reach + // without signing out of GitHub first. Connections are still rows, so this + // is a product decision only. allowsMultipleAccounts: false, byHand: { kind: "link", placeholder: "https://github.com/acme/web/pull/123" }, }); diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 8539755..1cdb85b 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -24,6 +24,11 @@ export type AuthDescriptor = scopes: string[]; /** This install needs an app registration before anyone can connect. */ needsAppRegistration: boolean; + /** + * Which account to sign in as, since the redirect authorizes whoever the + * browser is already signed in as and the consent screen does not ask. + */ + note?: string; } | { kind: "token"; diff --git a/src/routes/connectors/$connectorId.tsx b/src/routes/connectors/$connectorId.tsx index a95ef00..38ac810 100644 --- a/src/routes/connectors/$connectorId.tsx +++ b/src/routes/connectors/$connectorId.tsx @@ -78,6 +78,16 @@ function ConnectorDetailPage() { ) : null} + {/* Before connecting and on every visit after: the account a redirect + signs in as is whichever one the browser already holds, so this is + the only place to say which one it should be. */} + {manifest.auth.kind === "oauth_redirect" && manifest.auth.note ? ( + + Which account to connect + {manifest.auth.note} + + ) : null} + {scanning && manifest.auth.kind === "qr_scan" ? ( Date: Mon, 5 Oct 2026 12:19:42 -0700 Subject: [PATCH 06/30] Trim the workflows to the team's jobs, and call GitHub a shared account. Plan and Implement for assigned issues give way to one Reply workflow, which keeps the old id so existing loops still run. Gmail's mail summary goes, since it was one person's inbox. Chat workflows name their bot, the schedule leads the list, and GitHub is the account the team sends work to, whether made for this or someone's own. The code-writing path stays in place with no workflow on it; its tests drive it through a test-only workflow. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++- docs/concepts.md | 6 +- docs/connectors.md | 21 ++-- docs/deploy.md | 2 +- docs/narrative.md | 6 +- src/components/loop-form.tsx | 2 +- src/connectors/feishu/manifest.ts | 10 +- src/connectors/github/manifest.ts | 95 ++++++------------ src/connectors/gmail/manifest.ts | 111 +------------------- src/connectors/gmail/poll.test.ts | 150 ---------------------------- src/connectors/gmail/poll.ts | 91 ----------------- src/connectors/gmail/runtime.ts | 24 +---- src/connectors/manifests.ts | 13 ++- src/connectors/schedule/manifest.ts | 2 +- src/connectors/slack/manifest.ts | 6 +- src/connectors/wechat/manifest.ts | 6 +- src/server/loops.test.ts | 18 +--- src/server/tasks.test.ts | 34 ++++++- 18 files changed, 118 insertions(+), 495 deletions(-) delete mode 100644 src/connectors/gmail/poll.test.ts delete mode 100644 src/connectors/gmail/poll.ts diff --git a/README.md b/README.md index 7ab6711..81467ac 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,12 @@ The story is in [docs/narrative.md](docs/narrative.md). The words for the parts | Comes in from | Loop | Writes back | | --- | --- | --- | -| GitHub | Review pull requests the bot is asked to review | A review, as a comment | -| GitHub | Plan issues assigned to the bot | A comment with a plan | -| GitHub | Implement issues assigned to the bot | A draft pull request | -| Slack bot | Do what I ask the bot (@mention or DM) | A reply in the thread | -| Feishu / Lark bot | Do what I ask the bot (@mention in a group) | A reply in the thread | -| Schedule | Do something on a schedule | The log, or a Slack or Lark channel | -| Gmail | Tell me about new mail | A short note wherever you look | -| WeChat | Do what I ask the bot | A reply in the chat | +| Schedule | Do something on a schedule | The log, or a Slack, Lark, or GitHub thread | +| GitHub | Review pull requests sent to the shared account | A review, as a comment | +| GitHub | Reply to issues assigned to the shared account | A comment on the issue | +| Slack bot | Do what I ask the Slack bot (@mention or DM) | A reply in the thread | +| Feishu / Lark bot | Do what I ask the Feishu / Lark bot (@mention in a group) | A reply in the thread | +| WeChat | Do what I ask the WeChat bot | A reply in the chat | A loop’s answer can go to any connector that accepts it, so a scheduled check can post to #oncall. @@ -60,7 +58,7 @@ npm install -g loopable-cli loopable start # App + Dispatcher, bound on all interfaces ``` -On the same computer, open `http://127.0.0.1:4321`. Connect GitHub from that address, signed in as your team's GitHub bot account rather than your own (see [GitHub: a shared bot account](docs/connectors.md#github-a-shared-bot-account)). Write a loop. On Runners, copy the join command and start a runner (this machine is fine): +On the same computer, open `http://127.0.0.1:4321`. Connect GitHub from that address, signed in as the account your team will send work to (see [GitHub: a shared account](docs/connectors.md#github-a-shared-account)). Write a loop. On Runners, copy the join command and start a runner (this machine is fine): ```bash loopable runner --url http://:4321 --token diff --git a/docs/concepts.md b/docs/concepts.md index befa1b4..d1cef67 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -18,9 +18,9 @@ There is no second path where “the local dispatcher runs the agent.” The age **Connector.** A kind of service: GitHub, Slack bot, Feishu / Lark bot, Gmail, WeChat, the clock. It declares what it can watch and what it can write. It is not an account. -**Connection.** One credential of a connector — a GitHub bot account, a Slack bot, a Feishu bot, a Gmail inbox. Credentials live in Loopable. Agents never see them. +**Connection.** One credential of a connector — the team's shared GitHub account, a Slack bot, a Feishu bot, a Gmail inbox. Credentials live in Loopable. Agents never see them. -**Workflow.** A whole job a connector already knows how to do, named the way a person would name it: “Review pull requests the bot is asked to review.” It owns what to watch for, what to ask the agent, and where the answer is written. A loop is one instance of a workflow with its knobs set. +**Workflow.** A whole job a connector already knows how to do, named the way a person would name it: “Review pull requests sent to the shared account.” It owns what to watch for, what to ask the agent, and where the answer is written. A loop is one instance of a workflow with its knobs set. A workflow is not a loop template. Creating a loop copies the prompt onto the loop, and the loop owns that copy from then on. The loop still points at the workflow for the rest: the query, how the answer is parsed, whether the agent gets a checkout. If a connector stops offering a workflow, existing loops remain readable and deletable but not runnable. @@ -72,7 +72,7 @@ The path does not change with how many runners you have. One runner on the same | Where | Loopable’s secret store | That host’s home directory | That host’s home directory | | Who uses it | App and Dispatcher, to read signals and write back | The agent, via git on that host | Only the agent process | -They stay apart even when two of them are GitHub. The Connection is the team's bot account, which watches and writes through the API. Runtime auth is git on that host, used by the agent when the prompt asks it to clone. Sign that git in as the same bot account, so a branch and its pull request come from one name. Loopable does not copy the Connection token onto a runner or into an agent. +They stay apart even when two of them are GitHub. The Connection is the team's shared account, which watches and writes through the API. Runtime auth is git on that host, used by the agent when the prompt asks it to clone. Loopable does not copy the Connection token onto a runner or into an agent. The agent’s environment never contains Loopable’s service credentials. When a job needs a repository, the prompt tells the agent to clone it. That uses the machine’s git, not the Connection. diff --git a/docs/connectors.md b/docs/connectors.md index bbd6b62..71070b6 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -11,23 +11,20 @@ Each connector is a folder under `src/connectors/`. It exports two halves, and t The pages render entirely from manifests. Adding a connector means adding a folder and one line in `src/connectors/manifests.ts` and `src/connectors/runtimes.ts`. No page changes. The contract lives in `src/connectors/types.ts`. -## GitHub: a shared bot account +## GitHub: a shared account -Connect GitHub as a bot account the team owns, such as `acme-loopable`, not as a person. Loopable is the team's agent, and on GitHub the bot account is that agent: +Connect GitHub as the account the team sends work to. On GitHub, that account is Loopable: -- **People send it work the normal way.** Request a review from it, or assign it an issue. The workflows watch `review-requested:@me` and `assignee:@me`, and `@me` is the connected account. On a personal account they would pick up that person's own reviews and issues. -- **What it writes appears under its name.** Reviews, comments, and draft pull requests come from the bot, not from a teammate who did not write them. -- **Its access is the team's choice.** OAuth asks for the `repo` scope, but the bot can only reach repositories it has been added to. Add it to the repositories, or to a team that holds them, and nothing else. -- **It does not leave with anyone.** A personal connection stops working when that person leaves or loses access. +- **People send it work the normal way.** Request a review from it, or assign it an issue. The workflows watch `review-requested:@me` and `assignee:@me`, and `@me` is the connected account. +- **What it writes appears under its name.** Reviews and comments come from that account. +- **Its access is what that account can reach.** OAuth asks for the `repo` scope, but the account only reaches repositories it has access to. -To set it up: +Which account to use: -1. Create a GitHub account for the bot, with an email address the team controls. Store its password and 2FA in the team's password manager, held by an admin. -2. Add it to the repositories Loopable should work in, with write access. Write access is what lets it review, comment, and open pull requests. On paid org plans the bot takes a seat. -3. Sign in to GitHub as the bot, then click Connect in Loopable. GitHub authorizes whichever account the browser is signed in as, so a private window that holds only the bot's session is the easiest way. -4. Sign the runner's git and `gh` in as the same bot. The agent clones and pushes with them, so the branch and the pull request then come from one name. +- **An account made for this**, such as `acme-loopable`, is the cleanest. Work sent to it is clearly for Loopable, what it writes is clearly from Loopable, and it does not stop working when someone leaves. Create it with an email address the team controls, keep its password and 2FA in the team's password manager, and add it to the repositories Loopable should work in. On paid org plans it takes a seat. +- **Your own account** works too, if you are happy to share it. Then every review requested from you and every issue assigned to you is picked up, and Loopable answers as you. -Loopable does not check that the account is a bot. It works with a personal account too, but the workflows will act on that person's reviews and issues and write as them. +To connect, sign in to GitHub as that account, then click Connect in Loopable. GitHub authorizes whichever account the browser is signed in as, so a private window that holds only that session is the easiest way. If jobs clone private repositories, sign the runner's git in as an account that can read them. A GitHub App was considered and set aside for now. An App cannot be requested as a reviewer or assigned an issue, so the workflows would need labels or mentions instead. Its installation tokens also expire after an hour, which does not suit the runner's git. diff --git a/docs/deploy.md b/docs/deploy.md index 0a12679..d44234e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -49,4 +49,4 @@ loopable runner --url http://192.168.1.10:4321 --token Open the UI as `http://192.168.1.10:4321/?token=` to copy the join command. Connect GitHub or Gmail from `http://127.0.0.1:4321` on this laptop. A Slack bot or Feishu / Lark bot is a pasted app credential, so it can be added from the LAN UI; it is not a Slack or Feishu login. -Each runner needs the agent CLI signed in, and git / `gh` if jobs clone. Sign git in as the team's GitHub bot account, the same one connected in Loopable. Google will not accept a private IP as an OAuth redirect, so Gmail login from another machine on the LAN will not work. GitHub is the same unless you click Connect on this laptop. +Each runner needs the agent CLI signed in, and git / `gh` if jobs clone. Sign git in as an account that can read the repositories jobs clone. Google will not accept a private IP as an OAuth redirect, so Gmail login from another machine on the LAN will not work. GitHub is the same unless you click Connect on this laptop. diff --git a/docs/narrative.md b/docs/narrative.md index 36a554d..46fd4c8 100644 --- a/docs/narrative.md +++ b/docs/narrative.md @@ -16,7 +16,7 @@ It is for small and mid-size teams. Work comes in from Slack, Lark, GitHub, or a The team leaves Loopable running. A signal comes in, a loop matches, the agent runs, the answer is written back. -- **No one has to be there.** A loop runs when the review is requested, when the bot is mentioned, or when the clock says so. +- **No one has to be there.** A loop runs when the review is requested, when a chat bot is mentioned, or when the clock says so. - **The process is shared.** A loop is one job done one way: what to watch, what to ask, where to answer. Anyone on the team can read it and change it. - **The knowledge is shared.** A loop works in a folder or a checkout of your repository. The `AGENTS.md` and skills there are what the agent knows, and they live in the team’s repository, not on someone’s laptop. - **Every run is recorded.** The inbox shows what came in, what the agent did, and what it wrote back, whether or not it wrote anything. @@ -38,8 +38,8 @@ An agent that runs with nobody watching has to be boring by default. - Agents start read-only in your folders. GitHub jobs work in a fresh checkout, never in someone’s working copy. - Service credentials stay in Loopable. The agent never sees them. -- GitHub is connected as a shared bot account the team owns, never someone’s own login. Its access is the repositories the team adds it to. -- A review is posted as a comment, never an approval. Code goes into a draft pull request, never a merge. +- GitHub is connected as one shared account the team sends work to. Its access is the repositories that account can reach. +- On GitHub it reviews and comments. A review is a comment, never an approval, and it does not push code. - A new loop records what is already waiting. It does not run it. - Loopable needs no public URL. It polls Slack, Lark, and GitHub. Nothing on the internet has to reach the office Mac. diff --git a/src/components/loop-form.tsx b/src/components/loop-form.tsx index 075ad2b..e01f5c7 100644 --- a/src/components/loop-form.tsx +++ b/src/components/loop-form.tsx @@ -220,7 +220,7 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { } help={ workflow.runsIn === "checkout" - ? "The agent clones the repository with that machine's git. Loopable's GitHub bot account is not used to clone, and is only used afterwards to write the review or open the pull request. Sign that machine's git in as the same bot, so the branch and the pull request come from one name." + ? "The agent clones the repository with that machine's git. Loopable's shared GitHub account is not used to clone; it only writes the answer afterwards, as a review or a comment." : workflow.runsIn === "folder" ? "This job reads real code to do its work, so it runs in a real checkout. It does not write there: anything it changed would be sitting in your working copy." : "The agent gets the files it was given and nothing else." diff --git a/src/connectors/feishu/manifest.ts b/src/connectors/feishu/manifest.ts index 067b9ab..c1d3b76 100644 --- a/src/connectors/feishu/manifest.ts +++ b/src/connectors/feishu/manifest.ts @@ -67,11 +67,11 @@ export const feishuManifest = defineManifest({ workflows: [ { id: "feishu.ask", - name: "Do what I ask the bot", + name: "Do what I ask the Feishu / Lark bot", summary: - "Runs your agent when someone @mentions the bot in a group it is in, and answers in the thread.", - trigger: "the bot is @mentioned in a group it is in", - writes: "a reply in the Feishu thread", + "Runs the agent when someone @mentions the Feishu / Lark bot in a group, and answers in the thread.", + trigger: "someone @mentions the Feishu / Lark bot in a group it is in", + writes: "a reply in the thread", runsIn: "folder", settings: [folder], prompt: ASK_PROMPT, @@ -85,7 +85,7 @@ export const feishuManifest = defineManifest({ actions: [ { id: "feishu.reply", - name: "Reply in Feishu", + name: "Reply in Feishu / Lark", summary: "Post a message back to the thread that asked, or to a chat you name.", target: [chat], accepts: ["text"], diff --git a/src/connectors/github/manifest.ts b/src/connectors/github/manifest.ts index 43232de..71c54b4 100644 --- a/src/connectors/github/manifest.ts +++ b/src/connectors/github/manifest.ts @@ -76,37 +76,22 @@ const REVIEW_PROMPT = [ "guessing.", ].join("\n"); -const PLAN_PROMPT = [ - "Write a short implementation plan for this issue.", +const REPLY_PROMPT = [ + "Answer this issue in a comment, the way a teammate who knows this codebase would.", "", - "Say what should change, in which files where you can tell, and what to watch", - "out for. Stay inside what the issue actually supports: if it is too vague to", - "plan, say what is missing rather than inventing the requirements.", + "Work out what the issue is asking for. If it is a question, answer it from the", + "code rather than from memory. If it reports a bug, find the likely cause and", + "point at the files and lines involved. If it asks for a change, say how you", + "would make it, where, and what to watch out for.", "", - "Do not write the implementation.", -].join("\n"); - -const IMPLEMENT_PROMPT = [ - "Implement this issue in the checkout you are in.", - "", - "Read enough of the surrounding code first that what you write looks like it", - "belongs: the same patterns, the same names, the same way of handling errors.", - "Where the project has tests for work like this, add one.", - "", - "Stay inside what the issue asks for. A change that also tidies three other", - "things is a change nobody can review. If the issue is too vague to implement,", - "or doing it properly needs a decision that is not yours to make, reply with", - "exactly NOTHING_TO_DO rather than guessing: an unwanted pull request costs", - "more attention than a missing one.", - "", - "Run the project's tests if you can work out how, and say in your description", - "whether you did and what happened.", + "Read the code; do not change it. If the issue is too vague to answer, say what", + "is missing rather than inventing it.", ].join("\n"); export const githubManifest = defineManifest({ id: "github", name: "GitHub", - tagline: "Pick up reviews and issues sent to your team's GitHub bot account.", + tagline: "Pick up reviews and issues sent to your team's shared GitHub account.", docsUrl: "https://docs.github.com/rest", icon: "Github", accent: "bg-neutral-900 text-white", @@ -114,19 +99,20 @@ export const githubManifest = defineManifest({ kind: "oauth_redirect", scopes: GITHUB_SCOPES, needsAppRegistration: true, - // The Connection is the team's agent on GitHub, not anyone's own login: - // whatever it writes appears under its name, and the work it picks up is - // whatever the team sends to it. - note: "Sign in as a shared bot account for your team, not your own. Reviews, comments and pull requests appear under its name, and it sees only the repositories you add it to. Sign in to GitHub as the bot first; GitHub authorizes whichever account the browser is signed in as.", + // The Connection is the team's agent on GitHub: whatever it writes appears + // under its name, and the work it picks up is whatever the team sends to + // it. An account made for this is cleanest, but a teammate's own works too + // if they are happy for it to be shared. + note: "Connect the account the team will send work to. An account made for this is cleanest, but your own works too if you are happy to share it. Reviews and comments appear under its name, and it sees only the repositories it can access. Sign in to GitHub as that account first; GitHub authorizes whichever account the browser is signed in as.", }, workflows: [ { id: "github.review_requested", - name: "Review pull requests the bot is asked to review", - summary: "Reads the change and posts a review whenever someone requests one from the bot account.", + name: "Review pull requests sent to the shared account", + summary: "Reads the change and posts a review when someone requests one from the shared account.", // Team requests matter more than they sound: in most repositories with a // CODEOWNERS file, review arrives addressed to a team rather than a person. - trigger: "someone requests a review from the bot account, directly or through a team it belongs to", + trigger: "someone requests a review from the shared account, directly or through a team it belongs to", // review-requested covers teams; user-review-requested would not. watches: "is:open is:pr review-requested:@me", writes: "a review on the pull request, as a comment rather than an approval", @@ -139,40 +125,24 @@ export const githubManifest = defineManifest({ actionId: "github.submit_review", }, { + // The id is the one "Plan issues assigned to me" had. Planning was one + // kind of answer to an issue; replying covers it and the rest, and loops + // made for planning keep the prompt they copied. id: "github.issue_assigned", - name: "Plan issues assigned to the bot", - summary: "Posts a short implementation plan when an issue is assigned to the bot account.", - trigger: "an issue is assigned to the bot account", - // is:issue matters: without it this would pick up your own pull requests. + name: "Reply to issues assigned to the shared account", + summary: "Reads the issue and the code, and answers it in a comment.", + trigger: "an issue is assigned to the shared account", + // is:issue matters: without it this would pick up pull requests too. watches: "is:open is:issue assignee:@me", writes: "a comment on the issue", settings: [repositories], - prompt: PLAN_PROMPT, - guidancePlaceholder: "Anything specific to this codebase worth knowing before planning.", + prompt: REPLY_PROMPT, + guidancePlaceholder: + "Anything specific to this codebase worth knowing before answering. For example: questions about billing should point at docs/billing.md.", answer: "text", runsIn: "checkout", actionId: "github.post_issue_comment", }, - { - id: "github.issue_implement", - name: "Implement issues assigned to the bot", - summary: "Writes the change and opens a draft pull request.", - trigger: "an issue is assigned to the bot account", - watches: "is:open is:issue assignee:@me", - writes: "a draft pull request", - // The only workflow that writes code, and so the only one whose agent - // gets somewhere to write. Kept apart from "Plan issues assigned to the bot" - // rather than replacing it: asking for a plan and asking for the change - // are different jobs, and which one an issue deserves is a judgement - // about the issue. - runsIn: "checkout", - settings: [repositories], - prompt: IMPLEMENT_PROMPT, - guidancePlaceholder: - "How work is done here. For example: every new endpoint needs a test, and we do not add dependencies without asking.", - answer: "code", - actionId: "github.open_pull_request", - }, ], actions: [ { @@ -195,20 +165,11 @@ export const githubManifest = defineManifest({ // a review arrives here with its findings written into the prose. accepts: ["text"], }, - { - id: "github.open_pull_request", - name: "Open a draft pull request", - summary: "Open a draft pull request for the branch the agent already pushed.", - // The agent cloned and pushed with the machine's git. This action only - // names the pull request through the Connection. - target: [], - accepts: ["code"], - }, ], // Nothing to configure per account: what the account can see is what GitHub // decides, and every narrowing choice belongs to a loop. settings: [], - // One bot account per team. The redirect also authorizes whichever account + // One shared account per team. The redirect also authorizes whichever account // the browser is already signed in as, so a second one is out of reach // without signing out of GitHub first. Connections are still rows, so this // is a product decision only. diff --git a/src/connectors/gmail/manifest.ts b/src/connectors/gmail/manifest.ts index 0390bcb..500e285 100644 --- a/src/connectors/gmail/manifest.ts +++ b/src/connectors/gmail/manifest.ts @@ -1,5 +1,4 @@ import { defineManifest } from "../define.ts"; -import type { SettingField } from "../types.ts"; /** * Reading is the whole of it. Gmail has no send scope here on purpose: an @@ -8,77 +7,10 @@ import type { SettingField } from "../types.ts"; */ export const GMAIL_SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]; -/** - * Gmail's own search, appended to what the workflow already asks. This is the - * setting that makes the difference between a useful loop and a firehose, so - * it leads. - */ -const extraQuery: SettingField = { - key: "extraQuery", - kind: "text", - label: "Narrow it down", - help: "Gmail search terms, added to the query above. For example: from:bank.example.com, or subject:invoice, or -from:notifications@github.com.", - placeholder: "from:boss@example.com", -}; - -/** - * A cap on how many mails one look can turn into runs, which is a cost limit - * rather than a filter: every mail past it is still noticed on the next poll, - * because nothing has marked it as dealt with. - */ -const perPoll: SettingField = { - key: "maxPerPoll", - kind: "select", - label: "At most, per look", - help: "Each mail costs a full agent run. A quiet mailbox never reaches this; a busy one should be narrowed above rather than raised here.", - options: [ - { value: "3", label: "3 messages" }, - { value: "10", label: "10 messages" }, - { value: "25", label: "25 messages" }, - ], - default: "10", -}; - -/** - * The setting that decides whether a quiet mailbox is quiet. Without it every - * receipt arrives as a notification saying a receipt arrived, which costs a - * run each and trains you to stop looking. - */ -const needsMeOnly: SettingField = { - key: "needsMeOnly", - kind: "boolean", - label: "Only tell me when it needs me", - help: "One cheap look at everything that arrived decides which mails are actually asking something of you, from the sender, subject and first line. The rest wait in the backlog with a reason, so you can still run one if this got it wrong.", - default: true, -}; - -const skipOwn: SettingField = { - key: "skipOwn", - kind: "boolean", - label: "Skip mail I sent", - help: "Your own messages show up in threads you are part of, and being told about what you just sent is not useful.", - default: true, -}; - -const TELL_ME_PROMPT = [ - "Read the email in EMAIL.md and tell me what it says.", - "", - "You are writing a phone notification, not a summary document. Two or three", - "sentences. No headings, no bullet lists, no preamble like \"this email is\".", - "Lead with who it is from and what they want.", - "", - "Say plainly if it needs something from me and by when. If there is a", - "deadline, an amount of money, a date, or a link I have to act on, include it", - "exactly as written rather than describing it.", - "", - "If it is an automated notification, a newsletter, or otherwise needs nothing", - "from me, say so in one line and stop.", -].join("\n"); - export const gmailManifest = defineManifest({ id: "gmail", name: "Gmail", - tagline: "Read new mail and have an agent tell you what matters.", + tagline: "Connect an inbox. There are no Gmail workflows yet.", docsUrl: "https://developers.google.com/gmail/api/guides", icon: "Gmail", // Gmail's own red, now that the mark is Gmail's own too. A near miss on a @@ -89,43 +21,10 @@ export const gmailManifest = defineManifest({ scopes: GMAIL_SCOPES, needsAppRegistration: true, }, - workflows: [ - { - id: "gmail.new_mail", - name: "Tell me about new mail", - summary: "Reads mail as it arrives and sends you a short account of it somewhere you look.", - trigger: "mail arrives in your inbox", - // A day's window rather than "unread": reading a mail on your phone - // should not decide whether the loop ever saw it. What stops a mail - // being handled twice is its id, not its state, so re-seeing yesterday's - // costs nothing and a poller that was off for an hour misses nothing. - watches: "in:inbox newer_than:1d", - writes: "a message telling you what arrived", - // Nothing to check out: the mail is the whole of the work, and it is - // handed over as a file. - runsIn: "temp", - settings: [extraQuery, needsMeOnly, perPoll, skipOwn], - prompt: TELL_ME_PROMPT, - guidancePlaceholder: - "What you care about in your mail. For example: I only need to know about anything involving money or a deadline.", - answer: "text", - // Gmail cannot write, so this answers on WeChat, which is the point of - // the workflow rather than an accident of it: mail you are already - // reading in Gmail does not need telling about. `source` would mean - // nothing here, since no chat started this. - actionConnectorId: "wechat", - actionId: "wechat.reply", - actionTarget: { to: "me" }, - // Half an hour, because mail turns up one piece at a time and the whole - // of deciding what is worth a run is having several to compare. Looking - // every two minutes meant almost always finding exactly one, which is - // not a batch, so every newsletter got an agent to itself. - // - // The cost of waiting is being told half an hour late about something - // that sat unread for hours before it was sent. - pollEveryMs: 30 * 60_000, - }, - ], + // No workflows for now. "Tell me about new mail" was one person's inbox, + // and Loopable is the team's agent. Connecting still works, so a workflow + // can come back without asking anyone to sign in again. + workflows: [], // Read-only, so there is nothing to offer. A loop built on Gmail writes // through whichever connector it picks. actions: [], diff --git a/src/connectors/gmail/poll.test.ts b/src/connectors/gmail/poll.test.ts deleted file mode 100644 index 346fc49..0000000 --- a/src/connectors/gmail/poll.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { pollGmail, queryFor } from "./poll.ts"; - -describe("queryFor", () => { - it("asks what the workflow says it watches", () => { - expect(queryFor("gmail.new_mail", {})).toBe("in:inbox newer_than:1d"); - }); - - it("appends what the loop added", () => { - expect(queryFor("gmail.new_mail", { extraQuery: "from:bank.example.com" })).toBe( - "in:inbox newer_than:1d from:bank.example.com", - ); - }); - - it("ignores an empty box rather than asking a query with a space on the end", () => { - expect(queryFor("gmail.new_mail", { extraQuery: " " })).toBe("in:inbox newer_than:1d"); - }); - - it("refuses a workflow it does not have", () => { - expect(() => queryFor("gmail.nonsense", {})).toThrow(/cannot watch/); - }); -}); - -type FakeMail = { id: string; from: string; subject?: string }; - -/** - * Stands in for Gmail. A poll lists ids and then asks for the headers of each, - * so those are the only two shapes the stub needs to know. - */ -function givenGmail(mails: FakeMail[]) { - const asked: string[] = []; - vi.stubGlobal("fetch", async (url: string) => { - asked.push(url); - const path = url.replace("https://gmail.googleapis.com/gmail/v1/users/me", ""); - if (path.startsWith("/messages?")) { - // Gmail leaves the key out entirely when nothing matches, which is the - // case worth reproducing rather than an empty array. - const body = mails.length > 0 ? { messages: mails.map(({ id }) => ({ id })) } : {}; - return new Response(JSON.stringify(body), { status: 200 }); - } - const one = /^\/messages\/([^?]+)/.exec(path); - if (one) { - const mail = mails.find((entry) => entry.id === one[1]); - if (!mail) throw new Error(`no such message: ${one[1]}`); - return new Response( - JSON.stringify({ - id: mail.id, - payload: { - headers: [ - { name: "From", value: mail.from }, - { name: "Subject", value: mail.subject ?? "Something" }, - ], - }, - }), - { status: 200 }, - ); - } - throw new Error(`unexpected request: ${url}`); - }); - return { asked }; -} - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -const poll = (settings: Record = {}) => - pollGmail({ - workflowId: "gmail.new_mail", - settings, - accessToken: "token", - emailAddress: "me@example.com", - }); - -describe("pollGmail", () => { - it("turns each message into a signal keyed by its id", async () => { - givenGmail([{ id: "18f0", from: "Jane ", subject: "Lunch?" }]); - const signals = await poll(); - expect(signals).toEqual([ - { - key: "mail#18f0", - kind: "email", - ref: "mail#18f0", - title: "Jane: Lunch?", - url: "https://mail.google.com/mail/u/0/#all/18f0", - payload: { messageId: "18f0" }, - }, - ]); - }); - - it("answers with nothing when the mailbox has nothing matching", async () => { - givenGmail([]); - expect(await poll()).toEqual([]); - }); - - /** - * The key is the id and nothing else, which is what stops a mail being - * acted on twice. Reading or filing it must not look like new work. - */ - it("keys on the message alone, not on anything that can change about it", async () => { - givenGmail([{ id: "18f0", from: "jane@example.com" }]); - const [first] = await poll(); - const [again] = await poll(); - expect(first!.key).toBe(again!.key); - }); - - it("skips your own mail by default, and keeps it when asked", async () => { - givenGmail([ - { id: "a", from: "Me " }, - { id: "b", from: "jane@example.com" }, - ]); - expect((await poll()).map((signal) => signal.ref)).toEqual(["mail#b"]); - expect((await poll({ skipOwn: false })).map((signal) => signal.ref)).toEqual([ - "mail#a", - "mail#b", - ]); - }); - - it("asks Gmail for no more than the loop's limit", async () => { - const { asked } = givenGmail([{ id: "a", from: "jane@example.com" }]); - await poll({ maxPerPoll: "3" }); - expect(asked[0]).toContain("maxResults=3"); - }); - - // A loop saved before a setting existed has it missing rather than false, - // and must behave as though the default had been there all along. - it("falls back to the workflow's defaults for anything unset", async () => { - const { asked } = givenGmail([{ id: "a", from: "me@example.com" }]); - expect(await poll({})).toEqual([]); - expect(asked[0]).toContain("maxResults=10"); - }); - - it("sends the loop's extra terms to Gmail rather than filtering afterwards", async () => { - const { asked } = givenGmail([{ id: "a", from: "jane@example.com" }]); - await poll({ extraQuery: "from:jane@example.com" }); - expect(decodeURIComponent(asked[0]!)).toContain("in:inbox newer_than:1d from:jane@example.com"); - }); - - it("refuses a workflow it does not have", async () => { - givenGmail([]); - await expect( - pollGmail({ - workflowId: "gmail.nonsense", - settings: {}, - accessToken: "token", - emailAddress: "me@example.com", - }), - ).rejects.toThrow(/cannot watch/); - }); -}); diff --git a/src/connectors/gmail/poll.ts b/src/connectors/gmail/poll.ts deleted file mode 100644 index 18544f3..0000000 --- a/src/connectors/gmail/poll.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { JsonValue } from "#/lib/domain.ts"; -import type { Signal } from "../types.ts"; -import { getMessageHeaders, listMessages, type GmailMessage } from "./api.ts"; -import { gmailManifest } from "./manifest.ts"; -import { addressOf, headerOf, mailRef, permalink, summarise } from "./mail.ts"; - -/** - * What the workflow says it watches for, which is what gets asked. Taken from - * the manifest rather than written again here, because it is shown on the loop - * page and a query that quietly differed from the one on screen would be the - * worst kind of wrong. - */ -function baseQuery(workflowId: string): string { - const workflow = gmailManifest.workflows.find((entry) => entry.id === workflowId); - if (!workflow?.watches) throw new Error(`Gmail cannot watch for ${workflowId}.`); - return workflow.watches; -} - -/** - * Missing means "the workflow's default", not "off". A loop saved before a - * setting existed must behave as if it had been there all along. - */ -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function count(value: unknown, fallback: number): number { - const parsed = typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN; - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function text(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -/** What Loopable asks Gmail: the workflow's query, then whatever the loop adds. */ -export function queryFor(workflowId: string, settings: Record): string { - const extra = text(settings.extraQuery); - return extra ? `${baseQuery(workflowId)} ${extra}` : baseQuery(workflowId); -} - -/** What the poll keeps, so the work can be picked up again later. */ -export type MailPayload = { messageId: string }; - -export async function pollGmail(input: { - workflowId: string; - settings: Record; - accessToken: string; - /** The connected mailbox, so mail you sent yourself can be recognised. */ - emailAddress: string; -}): Promise { - if (input.workflowId !== "gmail.new_mail") { - throw new Error(`Gmail cannot watch for ${input.workflowId}.`); - } - - const max = count(input.settings.maxPerPoll, 10); - const ids = await listMessages( - input.accessToken, - queryFor(input.workflowId, input.settings), - max, - ); - const skipOwn = boolean(input.settings.skipOwn, true); - const me = input.emailAddress.toLowerCase(); - - const signals: Signal[] = []; - for (const id of ids) { - // Headers only. The body is fetched when there is something to do with it, - // which for most of a mailbox is never. - const message: GmailMessage = await getMessageHeaders(input.accessToken, id); - if (skipOwn && addressOf(headerOf(message, "From")) === me) continue; - - signals.push({ - // The id, and nothing about the mail's state: a message is one piece of - // work whether or not it has since been read, replied to or filed. - key: mailRef(id), - kind: "email", - ref: mailRef(id), - title: summarise(message), - url: permalink(id), - // Gmail's own preview line, which the metadata fetch returns whether or - // not it is asked for. Enough to tell a bank statement from a person - // waiting on an answer, without fetching the body. - preview: (message.snippet ?? "").trim() || undefined, - // Only the id. Unlike a chat message, mail stays where it is and can be - // read again when the work actually runs, so keeping a copy of every - // body in SQLite would be paying to store the mailbox twice. - payload: { messageId: id } satisfies MailPayload as unknown as JsonValue, - }); - } - return signals; -} diff --git a/src/connectors/gmail/runtime.ts b/src/connectors/gmail/runtime.ts index e8e235b..1a9513c 100644 --- a/src/connectors/gmail/runtime.ts +++ b/src/connectors/gmail/runtime.ts @@ -2,7 +2,6 @@ import { defineRuntime } from "../define.ts"; import { oauthAppRegistration } from "../oauth-app.ts"; import { getMessage, getProfile } from "./api.ts"; import { mailContext, mailRef, permalink, summarise } from "./mail.ts"; -import { pollGmail, type MailPayload } from "./poll.ts"; import { buildAuthorizeUrl, createPkce, @@ -45,13 +44,16 @@ export async function usableToken( return { accessToken: refreshed.accessToken, refreshed }; } +/** What a task keeps about the mail it is for. */ +type MailPayload = { messageId: string }; + /** The mailbox, as a person would recognise it. */ function accountOf(emailAddress: string) { return { id: emailAddress, label: emailAddress }; } /** - * Which message this task is about. The poll keeps the id, and a pasted link + * Which message this task is about. A task keeps the id, and a pasted link * carries one too, so either is enough to fetch the mail again. */ function messageIdFrom(url: string, payload: unknown): string { @@ -91,24 +93,6 @@ export const gmailRuntime = defineRuntime({ }; }, - async poll({ workflowId, settings, credential }) { - const { accessToken } = await usableToken(credential as GmailCredential); - // Which mailbox this is, so mail from yourself can be told apart. Cheap, - // and the alternative is storing it on the credential where it would go - // stale without anything noticing. - const profile = await getProfile(accessToken); - // No cursor: Gmail is asked what matches now and answers completely, so - // there is no position to keep. - return { - signals: await pollGmail({ - workflowId, - settings, - accessToken, - emailAddress: profile.emailAddress, - }), - }; - }, - // No applyAction: this connector reads. A loop built on it answers through // whichever connector it is pointed at. diff --git a/src/connectors/manifests.ts b/src/connectors/manifests.ts index 4dcf49e..7c60ec2 100644 --- a/src/connectors/manifests.ts +++ b/src/connectors/manifests.ts @@ -61,12 +61,21 @@ export function connectorAction( return connectorManifest(connectorId)?.actions.find((entry) => entry.id === actionId); } -/** Every workflow on offer, for the page that asks which one to turn on. */ +/** + * Every workflow on offer, for the page that asks which one to turn on. + * + * The clock leads: it needs no account, and a scheduled job is the easiest + * first loop to try. The rest follow the connectors' own order. + */ export function allWorkflows(): Array<{ connector: ConnectorManifest; workflow: WorkflowDescriptor; }> { - return CONNECTOR_MANIFESTS.flatMap((connector) => + const ordered = [ + ...CONNECTOR_MANIFESTS.filter((connector) => connector.id === "schedule"), + ...CONNECTOR_MANIFESTS.filter((connector) => connector.id !== "schedule"), + ]; + return ordered.flatMap((connector) => connector.workflows.map((workflow) => ({ connector, workflow })), ); } diff --git a/src/connectors/schedule/manifest.ts b/src/connectors/schedule/manifest.ts index 944e956..d438bcd 100644 --- a/src/connectors/schedule/manifest.ts +++ b/src/connectors/schedule/manifest.ts @@ -84,7 +84,7 @@ export const scheduleManifest = defineManifest({ { id: "schedule.recurring", name: "Do something on a schedule", - summary: "Runs an agent in a folder you name, and keeps what it says.", + summary: "Runs the agent at the times you set, in a folder you name. The answer stays in the log, or goes to Slack, Lark, or GitHub.", trigger: "the time you set comes round", // No query to show. A clock is not something one can be written for, // and an invented one shown as if it were real is worse than nothing. diff --git a/src/connectors/slack/manifest.ts b/src/connectors/slack/manifest.ts index 40170d9..9f31e0c 100644 --- a/src/connectors/slack/manifest.ts +++ b/src/connectors/slack/manifest.ts @@ -53,9 +53,9 @@ export const slackManifest = defineManifest({ workflows: [ { id: "slack.ask", - name: "Do what I ask the bot", - summary: "Runs your agent when someone @mentions the bot or DMs it, and answers in the thread.", - trigger: "the bot is @mentioned, or someone DMs it", + name: "Do what I ask the Slack bot", + summary: "Runs the agent when someone @mentions the Slack bot or DMs it, and answers in the thread.", + trigger: "someone @mentions the Slack bot in a channel it is in, or DMs it", writes: "a reply in the Slack thread", runsIn: "folder", settings: [folder], diff --git a/src/connectors/wechat/manifest.ts b/src/connectors/wechat/manifest.ts index f333ea1..e11b817 100644 --- a/src/connectors/wechat/manifest.ts +++ b/src/connectors/wechat/manifest.ts @@ -85,9 +85,9 @@ export const wechatManifest: ConnectorManifest = { workflows: [ { id: "wechat.ask", - name: "Do what I ask the bot", - summary: "Runs your agent on a runner when you message the bot, and answers in the chat.", - trigger: "you send the bot a message", + name: "Do what I ask the WeChat bot", + summary: "Runs the agent when someone messages the WeChat bot, and answers in the chat.", + trigger: "someone sends the WeChat bot a message", // No query to show: messages arrive on a stream rather than being found // by asking, so there is nothing here that could be checked. writes: "a reply in the chat", diff --git a/src/server/loops.test.ts b/src/server/loops.test.ts index 7f11992..a97cd4c 100644 --- a/src/server/loops.test.ts +++ b/src/server/loops.test.ts @@ -97,22 +97,6 @@ describe("turning a workflow into a loop", () => { // write, and not with what the box on screen said. await expect(edited(id, { prompt: " " })).rejects.toThrow(/what the agent should do/); }); - - /** - * A read-only connector has no action to fall back to, so a workflow on one - * has to name where its answer goes or be useless the moment it is turned - * on. It still only starts there; the loop can be pointed anywhere after. - */ - it("starts on another connector when the workflow says so", async () => { - const loop = await added("gmail", "gmail.new_mail"); - - expect(loop.connectorId).toBe("gmail"); - expect(loop.actionConnectorId).toBe("wechat"); - expect(loop.actionId).toBe("wechat.reply"); - // "Whoever asked" is the action's own default and means nothing here, - // since no person started this. - expect(loop.actionTarget).toEqual({ to: "me" }); - }); }); /** @@ -144,7 +128,7 @@ describe("a loop that has to work somewhere", () => { }); it("does not ask for a local clone when the agent will clone the repository", async () => { - await expect(added("github", "github.issue_implement")).resolves.toBeTruthy(); + await expect(added("github", "github.issue_assigned")).resolves.toBeTruthy(); }); }); diff --git a/src/server/tasks.test.ts b/src/server/tasks.test.ts index 5b6da0e..7e7db52 100644 --- a/src/server/tasks.test.ts +++ b/src/server/tasks.test.ts @@ -67,6 +67,38 @@ vi.mock("#/connectors/runtimes.ts", () => ({ }), })); +// No shipped workflow writes code today, but the path that carries a pushed +// branch to a pull request is still here. This gives the tests one to drive it. +vi.mock("#/connectors/manifests.ts", async (importOriginal) => { + const actual = await importOriginal(); + const writesCode = { + id: "github.test_write_code", + name: "Write code for an issue", + summary: "Test only.", + trigger: "an issue is assigned", + writes: "a draft pull request", + runsIn: "checkout" as const, + settings: [], + prompt: "Implement this issue.", + answer: "code" as const, + actionId: "github.open_pull_request", + }; + const openPullRequest = { + id: "github.open_pull_request", + name: "Open a draft pull request", + summary: "Test only.", + target: [], + accepts: ["code" as const], + }; + return { + ...actual, + connectorWorkflow: (connectorId: string, workflowId: string) => + workflowId === writesCode.id ? writesCode : actual.connectorWorkflow(connectorId, workflowId), + connectorAction: (connectorId: string, actionId: string) => + actionId === openPullRequest.id ? openPullRequest : actual.connectorAction(connectorId, actionId), + }; +}); + vi.mock("./connections.ts", () => ({ credentialForConnector: async (id: string) => ({ credential: `credential for ${id}` }), })); @@ -367,7 +399,7 @@ describe("a loop that writes code", () => { name: "Implement things", priority: 1, connectorId: "github", - workflowId: "github.issue_implement", + workflowId: "github.test_write_code", prompt: PROMPT, settings: {}, actionConnectorId: "github", From 4178bb30930f5be6bb0b4d9e2913b1e3747963d4 Mon Sep 17 00:00:00 2001 From: imsobear Date: Mon, 5 Oct 2026 12:20:10 -0700 Subject: [PATCH 07/30] Show the trimmed workflow list on the site. Co-Authored-By: Claude Opus 5.5 --- site/index.html | 29 ++++++++++++++--------------- site/src/flow.ts | 10 +++++----- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/site/index.html b/site/index.html index 0e7493f..f85b252 100644 --- a/site/index.html +++ b/site/index.html @@ -190,7 +190,7 @@

    One agent the whole team hands work to.

    • No one has to be there

      -

      A loop runs when a review is requested, when the bot is mentioned, or when the clock says so.

      +

      A loop runs when a review is requested, when a chat bot is mentioned, or when the clock says so.

    • One way to do each job

      @@ -242,37 +242,36 @@

      It runs on your machine. You decide what it can do.

      Loops you can turn on now.

      • - GitHub - Review pull requests the bot is asked to review - A review, as a comment + Schedule + Do something on a schedule + The log, or a Slack, Lark, or GitHub thread
      • GitHub - Plan issues assigned to the bot - A comment with a plan + Review pull requests sent to the shared account + A review, as a comment
      • GitHub - Implement issues assigned to the bot - A draft pull request + Reply to issues assigned to the shared account + A comment on the issue
      • Slack bot - Do what I ask the bot + Do what I ask the Slack bot A reply in the thread
      • Lark bot - Do what I ask the bot + Do what I ask the Lark bot A reply in the thread
      • - Schedule - Do something on a schedule - The log, or a Slack or Lark channel + WeChat + Do what I ask the WeChat bot + A reply in the chat
      -

      Also Gmail and WeChat, for one person’s mail and chat.

      @@ -351,7 +350,7 @@

      Watch. Run. Write back.

      - Review or PR + Review or comment diff --git a/site/src/flow.ts b/site/src/flow.ts index 950c26b..57d834a 100644 --- a/site/src/flow.ts +++ b/site/src/flow.ts @@ -63,14 +63,14 @@ export const beats: Beat[] = [ askPlace: "acme/web#88", askVia: "GitHub", askWho: "Assigned", - askMsg: "Empty cart shows a blank page", + askMsg: "Why does an empty cart show a blank page?", agent: "Codex", - runLine: "Writing the fix, running tests", + runLine: "Reading the cart page", backKind: "github", - backPlace: "acme/web#91", + backPlace: "acme/web#88", backVia: "GitHub", - backWho: "Draft pull request", - backMsg: "Show an empty state, with a test", + backWho: "Comment", + backMsg: "CartList returns null when empty. Fix in cart.tsx:42", }, { kind: "clock", From 822262f7b48ffd7d29c9493ca8f4d26001f33b76 Mon Sep 17 00:00:00 2001 From: imsobear Date: Mon, 5 Oct 2026 12:29:14 -0700 Subject: [PATCH 08/30] Rework the loop page: a short form in three sections, and Tasks and Settings as links. The form drops its explanations, folds what the workflow fixes away, and picks where the answer goes from one grouped list. The loop page puts on/off and Run or Check in the header, says what it does in one line, and keeps the tab in the URL. Cards no longer pad their tops twice. Co-Authored-By: Claude Opus 5.5 --- src/components/loop-form.tsx | 496 +++++++++++---------------- src/components/page.tsx | 157 +++++++++ src/components/setting-fields.tsx | 14 +- src/connectors/feishu/manifest.ts | 4 +- src/connectors/github/manifest.ts | 6 +- src/connectors/schedule/manifest.ts | 6 +- src/connectors/slack/manifest.ts | 4 +- src/connectors/wechat/manifest.ts | 4 +- src/lib/time.ts | 10 + src/routes/__root.tsx | 2 +- src/routes/loops/$loopId.tsx | 502 +++++++++++++++------------- 11 files changed, 638 insertions(+), 567 deletions(-) create mode 100644 src/components/page.tsx create mode 100644 src/lib/time.ts diff --git a/src/components/loop-form.tsx b/src/components/loop-form.tsx index e01f5c7..9a3c811 100644 --- a/src/components/loop-form.tsx +++ b/src/components/loop-form.tsx @@ -1,21 +1,21 @@ import { useState } from "react"; import { useNavigate } from "@tanstack/react-router"; -import { ArrowRight, Bell } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { toast } from "sonner"; +import { Field, Section } from "@/components/page"; import { SettingFieldInputs, initialFieldValues } from "@/components/setting-fields"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { AGENT_MANIFESTS } from "@/agents/manifests.ts"; import { @@ -24,6 +24,7 @@ import { connectorManifest, connectorWorkflow, } from "@/connectors/manifests.ts"; +import type { WorkflowDescriptor } from "@/connectors/types.ts"; import type { ConnectionSettings, LoopEdit } from "@/lib/domain.ts"; import { saveLoop } from "@/server/functions/loops.ts"; @@ -31,7 +32,7 @@ const DEFAULT_AGENT = "__default"; /** Zero is stored as null: "as often as the dispatcher does" is not a duration. */ const POLL_CHOICES = [ - { value: "0", label: "Every time the dispatcher looks" }, + { value: "0", label: "Every couple of minutes" }, { value: String(10 * 60_000), label: "Every 10 minutes" }, { value: String(30 * 60_000), label: "Every 30 minutes" }, { value: String(60 * 60_000), label: "Every hour" }, @@ -41,50 +42,59 @@ const POLL_CHOICES = [ /** Connectors that can write at all, for the question of where the answer goes. */ const WRITERS = CONNECTOR_MANIFESTS.filter((entry) => entry.actions.length > 0); +/** One select value for a connector and one of its actions. */ +const destination = (connectorId: string, actionId: string) => `${connectorId}::${actionId}`; + +const RUNS_IN: Record, string> = { + checkout: "A fresh clone of the repository", + folder: "The folder this loop names", + temp: "A scratch folder with only what it was given", +}; + +const ANSWER: Record = { + review: "A summary, plus comments on lines", + code: "A change to the code", + text: "One block of text", +}; + /** - * Shown rather than hidden, and locked rather than editable. - * - * These are the parts of a workflow that only mean anything alongside the code - * that reads them: the query a connector sends, whether the agent gets a - * checkout, how its answer is parsed. Copying them onto a loop would freeze - * whatever was true the day it was made, and leaving them off the page would - * mean nobody could see what their loop actually does. + * The parts of a workflow a loop cannot change: what it asks the service, + * where the agent runs, and how the answer is read. Folded away, since they + * are worth checking once and never editing. */ -function Locked({ - label, - value, - help, - mono, -}: { - label: string; - value: string; - help: string; - mono?: boolean; -}) { +function WorkflowDetails({ workflow }: { workflow: WorkflowDescriptor }) { + const rows: Array<[string, React.ReactNode]> = []; + if (workflow.watches) { + rows.push(["Looks for", {workflow.watches}]); + } + rows.push(["Runs in", RUNS_IN[workflow.runsIn ?? "temp"]]); + rows.push(["Answer", ANSWER[workflow.answer]]); + return ( -
      - - -

      {help}

      -
      +
      + + + Fixed by the workflow + +
      + {rows.map(([label, value]) => ( +
      +
      {label}
      +
      {value}
      +
      + ))} +
      +
      ); } /** * A loop is one of a connector's workflows with its own answers to the - * questions it asks. Which of those a loop owns is the whole shape of this - * page: what it looks for and how the answer is read belong to the workflow - * and are locked, while what narrows it, where it writes and which agent runs - * it belong to the loop. + * questions it asks: what narrows it, which agent runs it and with what + * words, and where the answer goes. * * Serves a loop that exists and one that does not. A loop with no id has been - * chosen and not saved, so leaving this page is the end of it: nothing was - * written down, and nothing is watching for anything. + * chosen and not saved, so leaving this page is the end of it. */ export function LoopForm({ loop }: { loop: LoopEdit }) { const navigate = useNavigate(); @@ -96,7 +106,6 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { const [prompt, setPrompt] = useState(loop.prompt); const [guidance, setGuidance] = useState(loop.guidance ?? ""); const [agentId, setAgentId] = useState(loop.agentId ?? DEFAULT_AGENT); - const [enabled, setEnabled] = useState(loop.enabled); const [settings, setSettings] = useState(() => initialFieldValues(workflow?.settings ?? [], loop.settings), ); @@ -112,10 +121,10 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { const [saving, setSaving] = useState(false); const action = connectorAction(actionConnectorId, actionId); - const writerName = connectorManifest(actionConnectorId)?.name ?? actionConnectorId; + const sourceName = connector?.name ?? loop.connectorId; - /** Changing where the answer goes changes what has to be said about it. */ - const chooseAction = (connectorId: string, id: string) => { + const chooseDestination = (value: string) => { + const [connectorId, id] = value.split("::") as [string, string]; setActionConnectorId(connectorId); setActionId(id); setActionTarget(initialFieldValues(connectorAction(connectorId, id)?.target ?? [], {})); @@ -126,9 +135,8 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { This workflow is no longer offered - {connector?.name ?? loop.connectorId} used to have {loop.workflowId} and no - longer does, so this loop cannot run or be edited. Deleting it is the only thing left to - do with it. + {sourceName} no longer has {loop.workflowId}, so this loop cannot run or be + edited. You can still delete it. ); @@ -151,11 +159,16 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { actionId, actionTarget, pollEveryMs: Number(pollEveryMs) || null, - enabled, + // On/off lives in the page header, so this keeps whatever it is now. + enabled: loop.enabled, }, }); - toast.success(fresh ? `Added "${saved.name}"` : `Saved ${saved.name}`); - await navigate({ to: "/loops" }); + toast.success(fresh ? `Added ${saved.name}` : `Saved ${saved.name}`); + await navigate( + fresh + ? { to: "/loops/$loopId", params: { loopId: saved.id } } + : { to: "/loops/$loopId", params: { loopId: saved.id }, search: { tab: "settings" } }, + ); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } finally { @@ -163,227 +176,28 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { } }; - return ( -
      - - -
      - -
      -

      - When {workflow.trigger}, {connector?.name ?? loop.connectorId} hands it to an agent. -

      -

      - - {action - ? `Then, on ${writerName}: ${action.name.toLowerCase()}.` - : "Where the answer goes is no longer offered; choose again below."} -

      -
      -
      - -
      - - setName(event.target.value)} - /> -

      - Only for your benefit, and worth changing if you run this workflow more than once. -

      -
      - - 0 - ? ", before anything below narrows it further." - : "." - }${workflow.watches.includes("@me") ? " @me is the connected account, not you." : ""}` - : `${connector?.name ?? loop.connectorId} sends this over as it happens, and Loopable picks it up within a couple of minutes.` - } - /> + const crosses = action && actionConnectorId !== loop.connectorId; + const flattens = action && workflow.answer === "review" && !action.accepts.includes("review"); - - - - - {workflow.settings.length > 0 ? ( -
      -

      Only when

      - setSettings((prev) => ({ ...prev, [key]: value }))} - /> -
      - ) : null} - -
      -
      -

      Where the answer goes

      -

      - Usually back to whatever triggered the loop, which is what this started as. It does - not have to be, and it does not have to be the same service. -

      -
      - -
      -
      - - -
      - -
      - - -
      -
      - - {action ?

      {action.summary}

      : null} - - {/* Answering the thing that triggered the loop is only possible - when the two are on the same service, so a loop that crosses - has to be told where instead of being left to fail at the - write. */} - {action && actionConnectorId !== loop.connectorId ? ( - - This writes somewhere it did not read - - {writerName} has no way to answer something on{" "} - {connector?.name ?? loop.connectorId}, so choose below where this should land. - Both accounts have to be connected for the loop to run. - - - ) : null} - - {action && workflow.answer === "review" && !action.accepts.includes("review") ? ( -

      - This cannot attach a comment to a line, so the review arrives as prose with the - file and line of each point written into it. Nothing is dropped. -

      - ) : null} - - {action && action.target.length > 0 ? ( - setActionTarget((prev) => ({ ...prev, [key]: value }))} - /> - ) : null} -
      - -
      - -

      - {fresh - ? `Copied from “${workflow.name}”, and yours once you save. Editing it here changes nothing anywhere else, and a later version of that workflow will not change it back.` - : `Copied from “${workflow.name}” when this loop was made, and yours now. Editing it here changes nothing anywhere else, and a later version of that workflow will not change it back.`} -

      -