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
-
+
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:"}
diff --git a/src/routes/welcome.tsx b/src/routes/welcome.tsx
index 51529d2..92339d0 100644
--- a/src/routes/welcome.tsx
+++ b/src/routes/welcome.tsx
@@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { Landing, landingHead } from "@/landing/landing";
import { getViewer } from "@/server/functions/account.ts";
-/** The front page for anyone, and on a local install too. */
+/** The front page for anyone, and under `pnpm dev` too. */
export const Route = createFileRoute("/welcome")({
beforeLoad: async () => ({ signedIn: Boolean((await getViewer()).viewer) }),
head: landingHead,
diff --git a/src/runner/update.ts b/src/runner/update.ts
index db63f2d..ffd491b 100644
--- a/src/runner/update.ts
+++ b/src/runner/update.ts
@@ -3,7 +3,7 @@ import { writeFileSync } from "node:fs";
/**
* Whether this runner came from `npm install -g loopable-cli`, and so can
- * update itself the same way. One run from a checkout is updated with git.
+ * update itself the same way. One run from a checkout is updated by hand.
*/
export function isUpdatable(entry = process.argv[1] ?? ""): boolean {
return /[\\/]node_modules[\\/]loopable-cli[\\/]/.test(entry);
diff --git a/src/server/accounts.ts b/src/server/accounts.ts
index cb4ee68..4bf7c69 100644
--- a/src/server/accounts.ts
+++ b/src/server/accounts.ts
@@ -52,8 +52,8 @@ type Provider = {
registration: string;
/**
* The connection's own callback, shared so one registered address covers
- * both: GitHub and Google match a loopback callback only exactly. The
- * callback tells a sign-in apart by its state.
+ * both, signing in and connecting. The callback tells a sign-in apart by
+ * its state.
*/
callbackPath: string;
authorizeUrl(input: { clientId: string; redirectUri: string; state: string; challenge: string }): string;
@@ -302,7 +302,7 @@ export function clearedSessionCookie(secure: boolean): string {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? "; Secure" : ""}`;
}
-/** Who is looking at the app right now, for the page to show. Null on a local install. */
+/** Who is looking at the app right now, for the page to show. Null under `pnpm dev`. */
export async function viewerInScope(): Promise {
const scope = currentScope();
if (!scope?.userId) return null;
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 9d77c2a..86328d3 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -331,7 +331,7 @@ export const appSettings = sqliteTable(
*/
/**
* Credentials when App and Dispatcher do not share a keychain. Encrypted with
- * LOOPABLE_MASTER_KEY. Local laptop installs keep using the OS store.
+ * LOOPABLE_MASTER_KEY. `pnpm dev` keeps using the OS store.
*/
export const secrets = sqliteTable("secrets", {
key: text("key").primaryKey(),
diff --git a/src/server/limits.ts b/src/server/limits.ts
index 5e90061..9b81108 100644
--- a/src/server/limits.ts
+++ b/src/server/limits.ts
@@ -7,7 +7,7 @@ import { workspaceId } from "./workspace.ts";
/**
* What one workspace may use of the hosted service. Not a price list: a way to
* keep polling and storage bounded while there is no pricing, so one busy
- * workspace cannot slow everyone else down. A local install has none.
+ * workspace cannot slow everyone else down. `pnpm dev` has none.
*/
export type Limits = {
/** Loops in one workspace. */
diff --git a/src/server/paths.ts b/src/server/paths.ts
index 6e86b9e..6bc2f38 100644
--- a/src/server/paths.ts
+++ b/src/server/paths.ts
@@ -16,7 +16,7 @@ export function dbPath(): string {
return process.env.LOOPABLE_DB ?? join(dataDir(), "loopable.sqlite");
}
-/** Keychain service prefix. Dev must not read or write the deploy install's secrets. */
+/** Keychain service prefix. `pnpm dev` must not read or write the secrets of another local profile. */
export function secretNamespace(): string {
return process.env.LOOPABLE_SECRET_PREFIX ?? (isDevProfile() ? "loopable-dev" : "loopable");
}
diff --git a/src/server/poller.ts b/src/server/poller.ts
index 066af01..7048aff 100644
--- a/src/server/poller.ts
+++ b/src/server/poller.ts
@@ -13,15 +13,15 @@ export type Poller = ReturnType;
/**
* Asks every loop's connector what matches, over and over.
*
- * Polling rather than webhooks because Loopable runs on a laptop: there is no
- * address for GitHub to call back, and asking is something a laptop can do
- * from behind any network. Two minutes is slow enough to stay far inside
- * GitHub's search limit and fast enough that a review request feels picked up
- * rather than found later.
+ * Polling rather than webhooks for now: it needs nothing registered with each
+ * service, and works the same for every connector. Each loop is only asked
+ * when it is due, so this runs every minute, as often as a loop may ask; that
+ * stays far inside GitHub's search limit and picks a review request up while
+ * it is still fresh.
*/
export function createPoller(options: PollerOptions = {}) {
const run = options.poll ?? pollAllLoops;
- const intervalMs = options.intervalMs ?? 120_000;
+ const intervalMs = options.intervalMs ?? 60_000;
const log = options.log ?? (() => {});
let timer: ReturnType | undefined;
diff --git a/src/server/runner-api.ts b/src/server/runner-api.ts
index f0712e3..29d149e 100644
--- a/src/server/runner-api.ts
+++ b/src/server/runner-api.ts
@@ -69,7 +69,7 @@ export async function claimAgentJob(runnerId: string): Promise
if (jobMinProtocol(job) > protocolOf(row)) {
await updateTask(task.id, {
state: "failed",
- error: `${row.name} is too old for this job. Upgrade it with npm install -g loopable-cli, then run this again.`,
+ error: `${row.name} is too old for this job. Update it from the Runners page, then run this again.`,
leaseUntil: null,
});
continue;
diff --git a/src/server/runners.test.ts b/src/server/runners.test.ts
index 13545aa..56453f3 100644
--- a/src/server/runners.test.ts
+++ b/src/server/runners.test.ts
@@ -57,11 +57,11 @@ describe("pickRunner", () => {
);
});
- it("refuses folder jobs when no runner is on this host", async () => {
+ it("refuses an old absolute-folder job when no runner is on this host, and says to edit the loop", async () => {
await givenRunner("other-box");
await expect(
pickRunner({ agentId: "codex", requiresHost: true }),
- ).rejects.toThrow("same host as Loopable");
+ ).rejects.toThrow("absolute path from an older version");
});
it("prefers an idle runner that has the agent", async () => {
diff --git a/src/server/runners.ts b/src/server/runners.ts
index d7fb1d6..35df864 100644
--- a/src/server/runners.ts
+++ b/src/server/runners.ts
@@ -191,11 +191,13 @@ export async function pickRunner(input: {
const chosen = idle ?? candidates[0];
if (chosen) return chosen.id;
if (input.requiresHost) {
- throw new Error("This job needs a runner on the same host as Loopable.");
+ throw new Error(
+ "This loop's folder is an absolute path from an older version. Edit the loop and give the folder relative to the runner's home, such as code/web.",
+ );
}
if (capable.length > 0) {
throw new Error(
- "Every runner with this agent is too old for this job. Upgrade them with npm install -g loopable-cli.",
+ "Every runner with this agent is too old for this job. Update them from the Runners page, or with npm install -g loopable-cli, then loopable stop and loopable runner.",
);
}
throw new Error("No runner is online that can run this agent.");
@@ -241,7 +243,7 @@ async function workspaceForJoinToken(token: string): Promise {
.where(eq(workspaces.joinTokenHash, tokenHash(token)))
.get();
if (row) return row.id;
- // A local install from before workspaces kept its token only in the secret
+ // A database from before workspaces kept its token only in the secret
// store, so nothing has been hashed yet.
if (!isHosted() && token === (await secretStore().get(joinTokenKey(LOCAL_WORKSPACE)))) {
await rememberJoinToken(LOCAL_WORKSPACE, token);
@@ -429,7 +431,7 @@ export async function requestRunnerUpdate(id: string): Promise {
if (!row || row.forgottenAt) throw new Error("Unknown runner.");
if (!isRunnerOnline(row)) throw new Error(`${row.name} is offline, so it cannot update now.`);
if (row.updatable !== true) {
- throw new Error(`${row.name} cannot update itself. On that machine, run npm install -g loopable-cli.`);
+ throw new Error(`${row.name} cannot update itself. On that machine, run npm install -g loopable-cli, then loopable stop and loopable runner.`);
}
const version = await latestRunnerVersion();
if (!olderThan(row.version, version)) throw new Error(`${row.name} is already on v${row.version}.`);
diff --git a/src/server/settings.ts b/src/server/settings.ts
index 2ac4805..0baec8c 100644
--- a/src/server/settings.ts
+++ b/src/server/settings.ts
@@ -50,7 +50,7 @@ export async function deleteSystemSetting(key: string): Promise {
.run();
}
-/** New key first, then the old engine.* name so a laptop install keeps its pause. */
+/** New key first, then the old engine.* name so a database from before keeps its pause. */
export async function readSystemSettingRenamed(
key: string,
previous: string,
diff --git a/src/server/workspace.ts b/src/server/workspace.ts
index 3f8f526..cade22c 100644
--- a/src/server/workspace.ts
+++ b/src/server/workspace.ts
@@ -12,12 +12,12 @@ import { isHosted } from "#/lib/hosted.ts";
* everyone's rows.
*
* Asking with no scope set is an error when hosted, so a forgotten scope fails
- * loudly instead of reading across workspaces. On a local install there is
+ * loudly instead of reading across workspaces. Under `pnpm dev` there is
* only one workspace, so it is the answer.
*/
export type Scope = { workspaceId: string; userId: string | null };
-/** The one workspace of a local install, and of everything made before workspaces. */
+/** The one workspace of `pnpm dev`, and of everything made before workspaces. */
export const LOCAL_WORKSPACE = "local";
/** Settings that belong to the dispatcher itself, not to anyone's workspace. */
@@ -42,7 +42,7 @@ export function workspaceId(): string {
throw new Error("Sign in to Loopable first.");
}
-/** The signed-in person, when there is one. A local install has nobody signed in. */
+/** The signed-in person, when there is one. Under `pnpm dev` nobody signs in. */
export function currentUserId(): string | null {
return scope.getStore()?.userId ?? null;
}
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 6547a6e..0bddd86 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -8,7 +8,7 @@
"vars": {
// The App Worker never migrates; the dispatcher container does, once.
"LOOPABLE_SKIP_MIGRATE": "1",
- // Where OAuth callbacks, sign-in and runner logins come back to.
+ // Where OAuth callbacks and sign-in come back to, and the address runners join.
"LOOPABLE_BASE_URL": "https://loop.goodone.si"
},
// The site lives here now: the front page is the app's /. Secrets