diff --git a/.gitignore b/.gitignore index 6f6b2c7..91f029d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ scratch-*.ts *.sqlite-shm *.sqlite-wal config/oauth-app.json + +# Trying hosted mode locally (docs/hosting.md) +.hosted/ diff --git a/AGENTS.md b/AGENTS.md index 92f795d..16d978c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Loopable -Loopable puts your coding agent on a loop: workflows set up in the browser that run on the user's own machine. It is hosted at loop.goodone.si. +Prompt it when you're here; loop it when you're not. Loopable turns the work that keeps coming back into agent workflows, set up in the browser at loop.goodone.si. They run on the user's own machine with Codex or Claude Code, and answer where they were asked. Work comes in from GitHub, Slack, Lark, email, or the clock. A loop says what to do. Codex, Claude Code, or Cursor Agent does it on a runner on the user's machine, and the answer is written back where it was asked. Every run is recorded. The story is in `docs/narrative.md`. @@ -9,7 +9,7 @@ Work comes in from GitHub, Slack, Lark, email, or the clock. A loop says what to - TanStack Start (React + Vite) for the app, server routes and server functions - pnpm - Tailwind CSS with shadcn/ui -- SQLite for state (libSQL when hosted), encrypted secrets when hosted, the OS keychain locally +- SQLite for state (libSQL when hosted), encrypted secrets when hosted, the macOS keychain (or a file elsewhere) in `pnpm dev` - Hosted on Cloudflare: the app as a Worker, the dispatcher as a Container; see `docs/hosting.md` ## Workspaces diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b77a54 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 imsobear + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 52b6eb7..1a5b67b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Prompt it when you're here. Loop it when you're not. -Loopable turns the work that keeps coming back into agent workflows, in minutes. Set them up at [loop.goodone.si](https://loop.goodone.si); they run on your machine with Codex or Claude Code, and the answer goes back where it was asked. +Loopable turns the work that keeps coming back into agent workflows, in minutes. Set them up at [loop.goodone.si](https://loop.goodone.si); they run on your machine with Codex or Claude Code, and answer where they were asked. A review request, a question in #oncall, a morning check: today each one waits until you copy it into your agent, and when you are busy or away, it stops. Hosted agents can take some of it, but they run in a cloud sandbox that cannot reach your internal network, logs, or services. @@ -46,7 +46,7 @@ A loop’s answer can go to any connector that accepts it, so a scheduled check ## How it works

- Slack bot, GitHub, and Lark bot into Loopable. A runner pulls the job. Loopable writes back to the same three. + GitHub, Slack, Lark, Gmail and the clock start work in Loopable, which is hosted. A runner on your machine pulls the job and runs Codex or Claude Code. Loopable writes the answer back where it was asked.

Loopable is hosted: the app and the dispatcher run on Cloudflare. The dispatcher watches your services, matches a loop, and queues the job. Your runner pulls it over HTTPS, runs the agent on your machine, and Loopable writes the answer back. @@ -72,11 +72,11 @@ pnpm db:generate # after changing src/server/db/schema.ts pnpm db:studio ``` -`pnpm dev` is a single local workspace with no sign-in. To try sign-in, workspaces, and joining a runner with a token locally, see [docs/hosting.md](docs/hosting.md#trying-hosted-mode-locally). `pnpm runner` is only for a second machine while `pnpm dev` is running. +`pnpm dev` is a single local workspace with no sign-in. To try sign-in, workspaces, and joining a runner with a token locally, see [docs/hosting.md](docs/hosting.md#trying-hosted-mode-locally). `pnpm runner --token ` joins a second runner to `pnpm dev`, kept apart from your real one in `~/.loopable-dev`. ### Release and deploy -Changes go through a pull request; CI must pass before it merges. Merging to `main` deploys the service by itself, migrations first. The CLI is released by hand with the **Release** workflow. All of it, and how to roll back, is in [docs/release.md](docs/release.md). +Changes go through a pull request; CI must pass before it merges. Merging to `main` deploys the service by itself, migrations first. The CLI's version is bumped in a pull request with `pnpm bump`, then the **Release** workflow publishes it. All of it, and how to roll back, is in [docs/release.md](docs/release.md). | Path | Contents | | --- | --- | diff --git a/docs/architecture.svg b/docs/architecture.svg index bb8fd7e..734e8d5 100644 --- a/docs/architecture.svg +++ b/docs/architecture.svg @@ -1,110 +1,58 @@ - - Loopable architecture - Slack bot, GitHub, and Lark bot into Loopable. A runner pulls the job. Loopable writes the result back to Slack, GitHub, and Lark. - - - - - - - - - - - - - - - - - - - - - - - - - - - - SLACK BOT - - - - - GITHUB - - - - - LARK BOT - - - - - - - - Loopable - APP + DISPATCHER - - - - - WATCH - From Slack, GitHub, Lark. - The tools the team already uses. - - - - - MATCH - Pick the loop. - - - REVIEW - - CASES - - MONITORS - - - - - - DISPATCH - Queue the job. - A runner pulls it. - - - - - - PULL - - - - Runner - SHARED COMPUTER - Pulls the job. Codex or Claude run on that machine. Loopable writes the result back. - - - - - CODEX - - - - - CLAUDE - - - - - - WRITE BACK - - - + +How Loopable works +GitHub, Slack, Lark, Gmail and the clock start work in Loopable, which is hosted. A runner on your machine pulls the job and runs Codex or Claude Code. Loopable writes the answer back where it was asked, and keeps every run in the Inbox. + + +COMES IN FROM + +GitHub + +Slack bot + +Lark bot + +Gmail + +Schedule + + +something new: a review request, a mention, an email, the time + +Loopable +HOSTED · LOOP.GOODONE.SI + +1 · Watch +Looks at your services +for something new. + +2 · Match +Picks the first loop +that fits. + +3 · Queue +Holds the job +until a runner takes it. + + +your runner pulls the job over HTTPS + +Runner +YOUR MACHINE +Runs your agent with your code, your tools and your logins. + +Codex + +Claude Code + + +Loopable writes the answer back +WHERE IT WAS ASKED + +A review or comment + +A reply in the thread + +An email + +The Inbox diff --git a/docs/concepts.md b/docs/concepts.md index 0eeb402..54560e2 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -1,6 +1,6 @@ # Concepts -Loopable puts your coding agent on a loop. It is hosted at loop.goodone.si; the agent runs on your machine. It watches the services you already work in and finishes the work with a coding agent: it picks up a signal, does what the loop says, and writes the result back. Every run is recorded. Why it exists is in [narrative.md](narrative.md). +Loopable turns the work that keeps coming back into agent workflows. It is hosted at loop.goodone.si; the agent runs on your machine. It watches the services you already work in and finishes the work with a coding agent: it picks up a signal, does what the loop says, and writes the result back. Every run is recorded. Why it exists is in [narrative.md](narrative.md). ```text Signal → Loop → Task diff --git a/docs/connectors.md b/docs/connectors.md index 43226c2..ea2f427 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -56,6 +56,8 @@ Gmail is connected by signing in with Google. Hosted, Google sends the browser b Gmail asks for `gmail.readonly` and `gmail.send`. A connection made before sending was offered has to be reconnected to send. +Google has not verified Loopable yet, because reading mail is a scope it reviews first. Until it does, Google shows a warning when you connect: choose **Advanced**, then **Go to Loopable**. It also limits an unverified app to 100 users. + ## Slack bot The Slack connection is a **bot in a Slack app**, not a Slack user login. For now you create the Slack app yourself, install it in your Slack workspace, and paste the Bot User OAuth Token (`xoxb-…`). That token is not a person's Slack account. Loopable polls, so Slack never has to send it anything. An "Add to Slack" button, with Slack sending events, is planned now that Loopable has a public address. diff --git a/docs/hosting.md b/docs/hosting.md index be54d59..c4fe991 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -8,7 +8,7 @@ How the hosted service at loop.goodone.si is set up and deployed. This is for wh | --- | --- | --- | | App Worker | Cloudflare Worker `loopable`, at loop.goodone.si | Front page, the app, sign-in, the API runners call | | Dispatcher | Cloudflare Container beside the Worker | Watches services, queues jobs, writes answers back; applies migrations on start | -| Database | A libSQL server, such as Turso | Every workspace's loops, tasks, connections, encrypted secrets | +| Database | Turso, in `aws-us-west-2`; the Worker's `placement` in `wrangler.jsonc` sits next to it, and must move with it | Every workspace's loops, tasks, connections, encrypted secrets | | Runners | People's own machines | Run the agent; installed with `npm install -g loopable-cli` | The Worker wakes the dispatcher on every request and once a minute from a cron trigger. There is one dispatcher for everyone. @@ -37,9 +37,9 @@ The Worker wakes the dispatcher on every request and once a minute from a cron t Merging to `main` deploys: once CI passes there, the **Deploy** workflow migrates the database, builds the Worker and the dispatcher's container image (which needs Docker, so it is built there), and deploys both. Run it by hand to redeploy or roll back. The whole flow is in [release.md](release.md). -## Moving loop.goodone.si to the app +## Moving loop.goodone.si to the app (done) -loop.goodone.si used to point at the old site Worker, `loopable-web`. To hand it to the app: +Done in October 2026, and kept for reference. loop.goodone.si used to point at the old site Worker, `loopable-web`. To hand it to the app: 1. In the Cloudflare dashboard, under Workers & Pages → `loopable-web` → Settings → Domains & Routes, remove `loop.goodone.si`. 2. Run the **Deploy** workflow with **Also deploy the old site address as a redirect** ticked. The app takes loop.goodone.si, and `loopable-web.sobear-me.workers.dev` starts redirecting there. @@ -55,4 +55,4 @@ Each workspace can have up to 20 loops and 5 runners, and a loop is looked at no LOOPABLE_HOSTED=1 LOOPABLE_MASTER_KEY=local LOOPABLE_HOME=$PWD/.hosted PORT=4321 pnpm dev ``` -This runs the app, sign-in and workspaces on a local file database. Signing in uses the development apps in `config/oauth-app.json`, registered with callbacks on `http://127.0.0.1:4321`; copy `config/oauth-app.example.json` to start. Open the app at `http://127.0.0.1:4321`, not `localhost`, and stop `pnpm dev` first since it uses the same port. Connect a runner with the command on the Runners page, run from the checkout: `node --experimental-strip-types src/cli.ts runner --url http://127.0.0.1:4321 --token TOKEN`. +This runs the app, sign-in and workspaces on a local file database. Signing in uses the development apps in `config/oauth-app.json`, registered with callbacks on `http://127.0.0.1:4321`; copy `config/oauth-app.example.json` to start. Open the app at `http://127.0.0.1:4321`, not `localhost`, and stop `pnpm dev` first since it uses the same port. Connect a runner from the checkout with the token from the local Runners page: `pnpm runner --token TOKEN`. It joins `http://127.0.0.1:4321` and keeps its state in `~/.loopable-dev`, apart from a real runner. diff --git a/docs/narrative.md b/docs/narrative.md index ed12eb6..9d1112a 100644 --- a/docs/narrative.md +++ b/docs/narrative.md @@ -2,7 +2,7 @@ Prompt it when you're here. Loop it when you're not. -Loopable turns the work that keeps coming back into agent workflows, in minutes. You set them up in the browser at loop.goodone.si; they run on your machine with Codex or Claude Code, and the answer goes back where it was asked. +Loopable turns the work that keeps coming back into agent workflows, in minutes. You set them up in the browser at loop.goodone.si; they run on your machine with Codex or Claude Code, and answer where they were asked. It is for developers first, and the teams they work in. Work comes in from GitHub, Slack, Lark, email, or the clock, and every run is recorded. @@ -36,7 +36,7 @@ Loopable is hosted, so there is nothing to keep running but the runner. The runn - **Reach.** Whatever your machine can reach, the agent can reach: the internal network, logs, databases, internal services. - **Extend.** Any MCP server, CLI, or login you set up on that machine. No platform decides what is allowed. -- **Choose.** Codex, Claude Code, or Cursor Agent, and the model you want. +- **Choose.** Codex or Claude Code (Cursor Agent too), and the model you want. - **Keep local.** Your code, logs, and network access stay on your machine. The agent uses the plan you already pay for. What does pass through the service: connection tokens, which are encrypted and never handed to the agent, and the prompts and answers of each run, which make up the inbox. diff --git a/docs/runners.md b/docs/runners.md index 312c75f..0112f89 100644 --- a/docs/runners.md +++ b/docs/runners.md @@ -51,4 +51,4 @@ Run the same `loopable runner --token` on each. Every job goes to an online runn npm install -g loopable-cli ``` -Then `loopable stop` and `loopable runner` to restart it on the new version. The Runners page shows each runner's version and flags any that are older than the service. An older runner keeps working, but does not take jobs that need something newer, such as loops that work in a folder. +Then `loopable stop` and `loopable runner` to restart it on the new version. The Runners page shows each runner's version and flags any that are older than the newest `loopable-cli` on npm. An older runner keeps working, but does not take jobs that need something newer, such as loops that work in a folder. diff --git a/package.json b/package.json index 25d755a..fb61ef4 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "loopable": "dist/cli.mjs" }, "files": [ + "LICENSE", "dist/cli.mjs", "dist/runner.mjs" ], @@ -23,6 +24,7 @@ "url": "https://github.com/imsobear/loopable/issues" }, "homepage": "https://loop.goodone.si", + "keywords": ["coding-agent", "codex", "claude-code", "agent-workflows", "runner", "loopable"], "publishConfig": { "access": "public" }, diff --git a/public/og.png b/public/og.png index ab5ac0d..a41d8b1 100644 Binary files a/public/og.png and b/public/og.png differ diff --git a/src/cli.ts b/src/cli.ts index bbe7273..b41a0a0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,7 +24,9 @@ const fromSource = here.endsWith("/src"); const root = join(here, ".."); const node = process.execPath; -const DEFAULT_URL = "https://loop.goodone.si"; +/** pnpm runner from a checkout: a second runner for pnpm dev, kept apart from a real one. */ +const DEV = process.env.LOOPABLE_DEV === "1"; +const DEFAULT_URL = DEV ? "http://127.0.0.1:4321" : "https://loop.goodone.si"; type Flags = { url?: string; token?: string; foreground?: boolean; follow?: boolean }; @@ -92,7 +94,7 @@ function parse(argv: string[]): { command: string; flags: Flags } { type Saved = { url: string; joinToken: string }; function home(): string { - return process.env.LOOPABLE_HOME ?? join(homedir(), ".loopable"); + return process.env.LOOPABLE_HOME ?? join(homedir(), DEV ? ".loopable-dev/runner" : ".loopable"); } function savedPath(): string { diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index d2c032c..78514cf 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -77,7 +77,7 @@ function NavGroup({ label, items }: { label: string; items: NavItem[] }) { ); } -/** Who is signed in, and the way out. Absent on a local install, where nobody signs in. */ +/** Who is signed in, and the way out. Absent under `pnpm dev`, where nobody signs in. */ function SignedIn() { const { viewer } = useLoaderData({ from: "__root__" }); if (!viewer) return null; diff --git a/src/connectors/github/manifest.ts b/src/connectors/github/manifest.ts index 4d27427..e24bf91 100644 --- a/src/connectors/github/manifest.ts +++ b/src/connectors/github/manifest.ts @@ -91,7 +91,7 @@ const REPLY_PROMPT = [ export const githubManifest = defineManifest({ id: "github", name: "GitHub", - tagline: "Pick up reviews and issues sent to your team's shared GitHub account.", + tagline: "Pick up reviews requested from the connected account and issues assigned to it, and answer as it.", docsUrl: "https://docs.github.com/rest", icon: "Github", accent: "bg-neutral-900 text-white", diff --git a/src/connectors/github/runtime.ts b/src/connectors/github/runtime.ts index a885389..9314a37 100644 --- a/src/connectors/github/runtime.ts +++ b/src/connectors/github/runtime.ts @@ -102,9 +102,9 @@ export const githubRuntime = defineRuntime({ if (oauthAppRegistration("github")) return { ready: true }; return { ready: false, - reason: "This build has no GitHub app registration.", + reason: "No GitHub app is registered here.", fixHint: - "Set LOOPABLE_GITHUB_CLIENT_ID and LOOPABLE_GITHUB_CLIENT_SECRET to use your own OAuth app.", + "Set LOOPABLE_GITHUB_CLIENT_ID and LOOPABLE_GITHUB_CLIENT_SECRET on the Worker, or add it to config/oauth-app.json when developing.", }; }, identifyLink(url) { diff --git a/src/connectors/gmail/manifest.ts b/src/connectors/gmail/manifest.ts index 3af6e9e..d2c5f76 100644 --- a/src/connectors/gmail/manifest.ts +++ b/src/connectors/gmail/manifest.ts @@ -89,7 +89,7 @@ export const gmailManifest = defineManifest({ pollEveryMs: 30 * 60_000, }, ], - // No ready-made job: what to do with mail is the team's to say, in a + // No ready-made job: what to do with mail is yours to say, in a // custom loop. workflows: [], actions: [ diff --git a/src/connectors/gmail/runtime.ts b/src/connectors/gmail/runtime.ts index 24291d8..f072a30 100644 --- a/src/connectors/gmail/runtime.ts +++ b/src/connectors/gmail/runtime.ts @@ -67,8 +67,8 @@ export const gmailRuntime = defineRuntime({ if (oauthAppRegistration("gmail")) return { ready: true }; return { ready: false, - reason: "This build has no Google app registration.", - fixHint: "Set LOOPABLE_GMAIL_CLIENT_ID and LOOPABLE_GMAIL_CLIENT_SECRET to use your own OAuth app.", + reason: "No Google app is registered here.", + fixHint: "Set LOOPABLE_GMAIL_CLIENT_ID and LOOPABLE_GMAIL_CLIENT_SECRET on the Worker, or add it to config/oauth-app.json when developing.", }; }, diff --git a/src/connectors/schedule/manifest.ts b/src/connectors/schedule/manifest.ts index e338889..10567db 100644 --- a/src/connectors/schedule/manifest.ts +++ b/src/connectors/schedule/manifest.ts @@ -29,7 +29,7 @@ const at: SettingField = { key: "at", kind: "text", label: "At", - help: "24-hour time, in this host's time zone.", + help: "24-hour time, in UTC.", placeholder: "09:00", }; diff --git a/src/connectors/schedule/schedule.ts b/src/connectors/schedule/schedule.ts index 407a627..f35c5ea 100644 --- a/src/connectors/schedule/schedule.ts +++ b/src/connectors/schedule/schedule.ts @@ -105,8 +105,8 @@ export function lastDue(schedule: Schedule, now: Date): Date { /** * The run owed since `after`, or null when none is. * - * At most one, and the most recent, on purpose. A laptop shut for a week owes - * a morning summary, not seven of them, and the one worth having is today's. + * At most one, and the most recent, on purpose. A dispatcher down for a week + * owes a morning summary, not seven of them, and the one worth having is today's. * Which of the missed ones is chosen matters because the caller records it: the * older ones are passed over rather than queued behind it. */ diff --git a/src/landing/landing.tsx b/src/landing/landing.tsx index 29627fc..1e69878 100644 --- a/src/landing/landing.tsx +++ b/src/landing/landing.tsx @@ -252,9 +252,9 @@ const WORKFLOWS: Array<{ icon: string; from: string; name: string; answer: strin { icon: "Github", from: "GitHub", name: "Review pull requests", answer: "A review, as a comment" }, { icon: "Github", from: "GitHub", name: "Reply to issues", answer: "A comment on the issue" }, { icon: "Slack", from: "Slack bot", name: "Do what I ask the Slack bot", answer: "A reply in the thread" }, - { icon: "Feishu", from: "Lark bot", name: "Do what I ask the Lark bot", answer: "A reply in the thread" }, + { icon: "Feishu", from: "Lark bot", name: "Do what I ask the Feishu / Lark bot", answer: "A reply in the thread" }, { icon: "Wechat", from: "WeChat", name: "Do what I ask the WeChat bot", answer: "A reply in the chat" }, - { icon: "Gmail", from: "Gmail", name: "When an email arrives", answer: "An email back" }, + { icon: "Gmail", from: "Gmail", name: "Your own loop on an email arriving", answer: "An email back" }, { icon: "Clock", from: "Schedule", name: "Do something on a schedule", answer: "The Inbox, or any thread" }, ]; diff --git a/src/lib/callback.test.ts b/src/lib/callback.test.ts index 745adbc..bcdf142 100644 --- a/src/lib/callback.test.ts +++ b/src/lib/callback.test.ts @@ -26,7 +26,7 @@ describe("callback URLs", () => { it("keeps loopback when BASE_URL is unset", () => { delete process.env.LOOPABLE_BASE_URL; expect(registrableCallbackUrl("github")).toBe( - "http://127.0.0.1/api/connectors/github/callback", + "http://127.0.0.1:4321/api/connectors/github/callback", ); expect(appOrigin("http://localhost:4321/x")).toBe("http://127.0.0.1:4321"); }); @@ -60,7 +60,7 @@ describe("callback URLs", () => { ); expect(appOrigin("http://192.168.1.10:4321/x")).toBe("http://127.0.0.1:4321"); expect(registrableCallbackUrl("github")).toBe( - "http://127.0.0.1/api/connectors/github/callback", + "http://127.0.0.1:4321/api/connectors/github/callback", ); expect(loopableOrigin()).toBe("http://192.168.1.10:4321"); delete process.env.LOOPABLE_BASE_URL; diff --git a/src/lib/callback.ts b/src/lib/callback.ts index d348b6d..62a907f 100644 --- a/src/lib/callback.ts +++ b/src/lib/callback.ts @@ -59,5 +59,7 @@ export function callbackUrl(requestUrl: string, connectorId: string): string { export function registrableCallbackUrl(connectorId: string): string { const hosted = publicConfiguredOrigin(); if (hosted) return `${hosted}/api/connectors/${connectorId}/callback`; - return `http://127.0.0.1/api/connectors/${connectorId}/callback`; + // GitHub matches a loopback callback with its port, so the development + // apps are registered on the port pnpm dev serves. + return `http://127.0.0.1:${process.env.PORT ?? "4321"}/api/connectors/${connectorId}/callback`; } diff --git a/src/lib/gaps.test.ts b/src/lib/gaps.test.ts index 2ffe4a4..21e2143 100644 --- a/src/lib/gaps.test.ts +++ b/src/lib/gaps.test.ts @@ -118,7 +118,7 @@ describe("issuesFor", () => { ).toEqual([]); }); - it("pins a loop with an old absolute folder to a runner on this host", () => { + it("says a loop with an old absolute folder needs editing", () => { expect( issuesFor( { ...ready, hostAgentIds: [] }, @@ -131,7 +131,7 @@ describe("issuesFor", () => { actionId: "wechat.reply", }), ), - ).toEqual(["Daily look needs a runner on this host with Cursor Agent signed in."]); + ).toEqual(["Daily look works in an absolute folder from an older version. Edit it and give the folder relative to the runner's home."]); }); it("asks for an upgrade when only an older runner has the agent", () => { @@ -147,7 +147,7 @@ describe("issuesFor", () => { actionId: "wechat.reply", }), ), - ).toEqual(["Daily look needs a newer runner. Upgrade the runners with npm install -g loopable-cli."]); + ).toEqual(["Daily look needs a newer runner. Update the runners from the Runners page."]); }); it("lets a loop with a relative folder run on any runner", () => { diff --git a/src/lib/gaps.ts b/src/lib/gaps.ts index 9ece3c7..161dce3 100644 --- a/src/lib/gaps.ts +++ b/src/lib/gaps.ts @@ -45,9 +45,9 @@ export function issuesFor(readiness: LoopReadiness, loop: LoopView): string[] { } else if (!pool.includes(agentId)) { issues.push( needs === "host" - ? `${loop.name} needs a runner on this host with ${agentName(agentId)} signed in.` + ? `${loop.name} works in an absolute folder from an older version. Edit it and give the folder relative to the runner's home.` : needs === "folder" && readiness.availableAgentIds.includes(agentId) - ? `${loop.name} needs a newer runner. Upgrade the runners with npm install -g loopable-cli.` + ? `${loop.name} needs a newer runner. Update the runners from the Runners page.` : `${loop.name} uses ${agentName(agentId)}, which no online runner has signed in.`, ); } diff --git a/src/lib/hosted.ts b/src/lib/hosted.ts index b1835d8..ab4b005 100644 --- a/src/lib/hosted.ts +++ b/src/lib/hosted.ts @@ -1,6 +1,6 @@ /** * Hosted Loopable shares a network database between the App Worker and the - * Dispatcher. A local laptop still uses a file, and needs none of this. + * Dispatcher. `pnpm dev` uses a file, and needs none of this. */ export function isHosted(): boolean { // Forces hosted behaviour on a file database, to try sign-in and workspaces locally. diff --git a/src/routes/api/connectors/$connectorId/callback.ts b/src/routes/api/connectors/$connectorId/callback.ts index 837ce46..4659bf8 100644 --- a/src/routes/api/connectors/$connectorId/callback.ts +++ b/src/routes/api/connectors/$connectorId/callback.ts @@ -11,8 +11,7 @@ export const Route = createFileRoute("/api/connectors/$connectorId/callback")({ handlers: { GET: async ({ request, params }) => { // Signing in comes back here too, so one registered address covers - // both: GitHub and Google match a loopback callback only exactly. - // The state says which it was. + // both, signing in and connecting. The state says which it was. const signIn = await signInProviderFor(new URL(request.url).searchParams.get("state")); if (signIn) return finishSignIn(request, signIn); diff --git a/src/routes/connectors/$connectorId.tsx b/src/routes/connectors/$connectorId.tsx index b94deb2..4d8b1eb 100644 --- a/src/routes/connectors/$connectorId.tsx +++ b/src/routes/connectors/$connectorId.tsx @@ -77,7 +77,7 @@ function ConnectorDetailPage() { {!state.readiness.ready ? ( - Not available in this build + Not set up {state.readiness.reason} {state.readiness.fixHint ? {state.readiness.fixHint} : null} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 41e2036..f06c596 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -5,7 +5,7 @@ import { getViewer } from "@/server/functions/account.ts"; /** * The front page. Signed in, it is still here to read, with a way into the * app; a link that sent someone to sign in first (`?next=`) goes on to where - * it was heading. A local install has nobody to sign in, so it opens the app. + * it was heading. Under `pnpm dev` nobody signs in, so it opens the app. */ export const Route = createFileRoute("/")({ validateSearch: (search: Record): { next?: string; error?: string } => ({ diff --git a/src/routes/privacy.tsx b/src/routes/privacy.tsx index 8605a33..be4a001 100644 --- a/src/routes/privacy.tsx +++ b/src/routes/privacy.tsx @@ -14,7 +14,7 @@ function Privacy() {

- Loopable runs workflows for your coding agent. The service at loop.goodone.si holds your loops, + Loopable turns the work that keeps coming back into workflows your coding agent runs. The service at loop.goodone.si holds your loops, your connections and the record of each run; the agent itself runs on your own machine. This page says what Loopable keeps, why, and how to have it removed.

diff --git a/src/routes/runners/index.tsx b/src/routes/runners/index.tsx index ff296be..0d16c85 100644 --- a/src/routes/runners/index.tsx +++ b/src/routes/runners/index.tsx @@ -83,7 +83,7 @@ function RunnersPage() {

Join a runner

- {hosted ? "On your machine:" : "On this machine or another on the network:"} + {hosted ? "On your machine:" : "From this checkout, for pnpm dev:"}