diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..85609a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +# Every pull request, and main after a merge: the same checks a release runs, +# so nothing reaches npm that has not passed here first. +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + # The version comes from packageManager in package.json. + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build the CLI + run: pnpm build + + - name: Build the site + run: pnpm site:build 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..a906cc9 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). @@ -15,16 +15,16 @@ 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 | -| 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 | +| Any trigger | Custom loop: pick what starts it, write your own prompt | Anywhere below, or Inbox only | +| Schedule | Do something on a schedule | Inbox, or a Slack, Lark, or GitHub thread | +| GitHub | Review pull requests | A review, as a comment | +| GitHub | Reply to issues | 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 | +| Gmail | Custom loop: “An email arrives” | Anywhere, such as an email back to the same inbox | -A loop’s answer can go to any connector that accepts it, so a scheduled check can post to #oncall. +A loop’s answer can go to any connector that accepts it, so a scheduled check can post to #oncall. To ask an agent something once, use **Ask an agent** in the inbox. ## Architecture @@ -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,16 +53,17 @@ 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 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 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 +npm install -g loopable-cli # on another machine; already done here loopable runner --url http://:4321 --token ``` @@ -89,6 +90,17 @@ pnpm db:studio `pnpm runner` is only for a second machine while `pnpm dev` is already running. +### Release + +CI runs typecheck, tests, and both builds on every pull request and on `main`. To publish `loopable-cli`, merge to `main`, then from a clean, pushed `main`: + +```bash +pnpm release minor --dry-run # every check, nothing changed +pnpm release minor # or patch, major, or an exact x.y.z +``` + +It checks git and npm, runs typecheck, tests, and build, then bumps the version, commits, tags `vX.Y.Z`, publishes, and pushes. Runners on other machines need `npm install -g loopable-cli` too. The site deploys on its own with `pnpm site:deploy`. + The public site is `site/` (static HTML, Vite). `pnpm site:dev` to preview. `pnpm site:build` writes `site/dist` for a Cloudflare Worker with assets only. `pnpm site:deploy` when you are ready to publish it. | Path | Contents | diff --git a/docs/concepts.md b/docs/concepts.md index bf49d13..5baa9dc 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -18,27 +18,35 @@ 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 — 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 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. +A connector registers three lists: -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. +- **Trigger** (“Starts when” in the UI). Something it can watch for: “A GitHub review is requested”, “An email arrives”. It owns the query, where the agent runs, and how the answer is read. +- **Workflow.** A ready-made job on one trigger, named the way a person would name it: “Review pull requests”. It adds a prompt and where the answer goes by default. +- **Action** (“Send to” in the UI). Somewhere an answer can go: “Submit a review”, “Reply in Slack”, “Email this account”. + +A loop is a workflow with its knobs set, or, for a custom loop, a trigger with your own prompt. Either way it can send its answer to any connector's action. + +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 trigger for the rest: the query, how the answer is parsed, whether the agent gets a checkout. If a connector stops offering a trigger, existing loops remain readable and deletable but not runnable. The UI does not need to say “workflow.” New loop is choosing a job by the name the connector gave it. In code and in this document the word is workflow, and the column is `workflowId`. -**Loop.** One instance of a workflow: your repositories, your notes, which agent. This is what people edit. Loops run by themselves. A loop names an agent, not a runner. The dispatcher picks an online runner that has that agent signed in. If none does, the loop is saved anyway and the page says it cannot run yet. +**Loop.** One instance of a workflow, or a custom loop: a trigger with your own prompt. It holds your settings, which agent, the folder it works in, and where the answer goes. This is what people edit. Loops run by themselves. A loop names an agent, not a runner. The dispatcher picks an online runner that has that agent signed in. If none does, the loop is saved anyway and the page says it cannot run yet. **Signal.** One thing a loop noticed. The connector’s key on a signal has to change exactly when there is something new to do. Same signal does not run twice. The first look never acts. Whatever is already waiting when a loop is created is that loop’s backlog, recorded rather than run. -**Task.** One run of one loop against one signal: fetch the context, let the agent work, write the result back. The inbox is the list of tasks. A task is kept whether it wrote anything or not. +**Task.** One run of one loop against one signal: fetch the context, let the agent work, write the result back. A prompt asked from the inbox with “Ask an agent” is a task with no loop, and nothing to write back. The inbox is the list of tasks. A task is kept whether it wrote anything or not. + +**Folder.** Loops that read your code (chat bots and schedules) work in a folder, given relative to the runner's home, such as `code/web`. Each runner looks for it under its own home, so any runner with that checkout can take the job. ## 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,11 +76,11 @@ 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 | -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 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 b3c17b3..aee1704 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -11,6 +11,23 @@ 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 account + +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. +- **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. + +Which account to use: + +- **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. + +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. + ## 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: @@ -37,7 +54,16 @@ WeChat is reached through Tencent's iLink bot API. Loopable speaks it directly r Connecting means scanning. The connector hands over what the code should contain and a way to ask how the scan is going; the page renders it and asks every couple of seconds. A code lasts about two minutes. The token never reaches the browser: a confirmed scan is saved server-side. -There are no WeChat workflows yet. Binding an account is worth having on its own. +A loop fires when someone messages the bot, and the answer goes back to whoever asked. WeChat only delivers a reply into a conversation someone started, so it is a place to answer, not a place to send news to. + +## Gmail + +Gmail is connected by signing in with Google, from `http://127.0.0.1:4321` on the Loopable host. + +- **Starts when an email arrives.** A custom loop on “An email arrives” checks the inbox every 30 minutes. It can be narrowed with Gmail search terms, skip mail the account sent, and skip mail that needs nothing, after one quick look at what arrived. +- **Email this account.** As an answer, Gmail sends to the connected account itself, the way a bot answers whoever asked. When the run started from an email in the same inbox, the answer joins that thread. + +Gmail asks for `gmail.readonly` and `gmail.send`. A connection made before sending was offered has to be reconnected to send. ## Slack bot diff --git a/docs/deploy.md b/docs/deploy.md index b661bab..a6f6c93 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. A loop that works in a folder names it relative to the runner's home, such as `code/web`, so check that folder out under the same path on every runner that should take those jobs. 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 61d4d0d..47f3156 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 @@ -16,9 +16,9 @@ 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. -- **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. +- **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 starts it, what to ask, where to answer. Start from a ready-made workflow, or write a custom loop on any trigger. Anyone on the team can read it and change it. +- **The knowledge is shared.** A loop works in a checkout of your repository, named relative to the runner's home so every runner finds its own copy. 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. It is not a better personal Codex. One person, one chat, one laptop is already solved. @@ -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 @@ -38,7 +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. -- 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. @@ -53,12 +54,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. +The runner claims work over HTTP. Loopable does not push into the runner. A Slack bot, a Lark bot, GitHub, an email, 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, custom loops, 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/drizzle/0025_prompt_tasks_done.sql b/drizzle/0025_prompt_tasks_done.sql new file mode 100644 index 0000000..646123b --- /dev/null +++ b/drizzle/0025_prompt_tasks_done.sql @@ -0,0 +1,2 @@ +-- An inbox prompt has nothing to write, so a finished one is done, not prepared. +UPDATE `tasks` SET `state` = 'done' WHERE `source_kind` = 'prompt' AND `state` = 'prepared' AND `dry_run` = 0; diff --git a/drizzle/0026_guidance_into_prompt.sql b/drizzle/0026_guidance_into_prompt.sql new file mode 100644 index 0000000..c5f4f6b --- /dev/null +++ b/drizzle/0026_guidance_into_prompt.sql @@ -0,0 +1,3 @@ +-- Loops no longer have separate team notes. Keep what was written by moving it +-- onto the end of the prompt, where it was sent anyway. +UPDATE `loops` SET `prompt` = `prompt` || char(10) || char(10) || trim(`guidance`), `guidance` = NULL WHERE `guidance` IS NOT NULL AND trim(`guidance`) <> ''; diff --git a/drizzle/0027_relative_folders.sql b/drizzle/0027_relative_folders.sql new file mode 100644 index 0000000..f45bbbd --- /dev/null +++ b/drizzle/0027_relative_folders.sql @@ -0,0 +1,5 @@ +-- A loop's folder is relative to the runner's home now. Paths under a home +-- folder become the part after it; anything else is left as it was. +UPDATE `loops` SET `settings` = json_set(`settings`, '$.folder', substr(json_extract(`settings`, '$.folder'), 8 + instr(substr(json_extract(`settings`, '$.folder'), 8), '/'))) WHERE json_extract(`settings`, '$.folder') LIKE '/Users/%/%'; +--> statement-breakpoint +UPDATE `loops` SET `settings` = json_set(`settings`, '$.folder', substr(json_extract(`settings`, '$.folder'), 7 + instr(substr(json_extract(`settings`, '$.folder'), 7), '/'))) WHERE json_extract(`settings`, '$.folder') LIKE '/home/%/%'; diff --git a/drizzle/0028_runner_version.sql b/drizzle/0028_runner_version.sql new file mode 100644 index 0000000..e357522 --- /dev/null +++ b/drizzle/0028_runner_version.sql @@ -0,0 +1,5 @@ +-- What a runner says it is: its package version, to show, and the protocol it +-- speaks, to decide which jobs it can take. Runners before 0.3 send neither. +ALTER TABLE `runners` ADD `version` text; +--> statement-breakpoint +ALTER TABLE `runners` ADD `protocol` integer; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 324ebcb..dc31808 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -176,6 +176,34 @@ "when": 1789100000009, "tag": "0024_prompt_tasks", "breakpoints": true + }, + { + "idx": 25, + "version": "6", + "when": 1789100000010, + "tag": "0025_prompt_tasks_done", + "breakpoints": true + }, + { + "idx": 26, + "version": "6", + "when": 1789100000011, + "tag": "0026_guidance_into_prompt", + "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1789100000012, + "tag": "0027_relative_folders", + "breakpoints": true + }, + { + "idx": 28, + "version": "6", + "when": 1789100000013, + "tag": "0028_runner_version", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 122389b..7008344 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "engines": { "node": ">=22" }, + "packageManager": "pnpm@11.22.0", "repository": { "type": "git", "url": "git+https://github.com/imsobear/loopable.git" @@ -42,11 +43,12 @@ "db:generate": "drizzle-kit generate", "db:studio": "LOOPABLE_DEV=1 drizzle-kit studio", "typecheck": "tsc --noEmit", + "release": "node scripts/release.mjs", "deploy": "LOOPABLE_CF=1 vite build && wrangler deploy", "site:dev": "pnpm --filter loopable-site dev", "site:build": "pnpm --filter loopable-site build", "site:deploy": "pnpm --filter loopable-site deploy", - "prepublishOnly": "pnpm build && pnpm typecheck" + "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build" }, "dependencies": { "@libsql/client": "^0.18.0" 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, diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..f3389d4 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,136 @@ +// Publish loopable-cli to npm from a clean, pushed main, so every version on +// npm is one tagged commit on GitHub. +// +// pnpm release patch|minor|major| +// pnpm release minor --dry-run checks everything, changes nothing +import { execFileSync, spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const bump = args.find((arg) => !arg.startsWith("--")); + +const problems = []; + +function say(line) { + console.log(line); +} + +function stop(message) { + console.error(`\n✗ ${message}`); + process.exit(1); +} + +/** A check that stops a real release, and is only reported in a dry run. */ +function check(ok, message) { + if (ok) return; + if (dryRun) { + problems.push(message); + say(` ! ${message}`); + } else { + stop(message); + } +} + +function git(...rest) { + return execFileSync("git", rest, { cwd: root, encoding: "utf8" }).trim(); +} + +function run(command, rest) { + say(`\n$ ${command} ${rest.join(" ")}`); + const result = spawnSync(command, rest, { cwd: root, stdio: "inherit" }); + if (result.status !== 0) stop(`${command} ${rest.join(" ")} failed.`); +} + +function next(current, how) { + if (/^\d+\.\d+\.\d+$/.test(how)) return how; + const [major, minor, patch] = current.split(".").map(Number); + if (how === "major") return `${major + 1}.0.0`; + if (how === "minor") return `${major}.${minor + 1}.0`; + if (how === "patch") return `${major}.${minor}.${patch + 1}`; + stop("Say how to bump: pnpm release patch|minor|major| [--dry-run]"); +} + +function newer(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] > pb[i]; + return false; +} + +const pkgPath = join(root, "package.json"); +const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); +const version = next(pkg.version, bump); +const tag = `v${version}`; + +say(`${pkg.name} ${pkg.version} → ${version}${dryRun ? " (dry run)" : ""}\n`); +check(newer(version, pkg.version), `${version} is not newer than ${pkg.version}.`); + +say("Checking git"); +check(git("rev-parse", "--abbrev-ref", "HEAD") === "main", "Release from main."); +check( + git("status", "--porcelain", "--untracked-files=no") === "", + "There are uncommitted changes. Commit or stash them first.", +); +git("fetch", "--quiet", "origin", "main", "--tags"); +check( + git("rev-parse", "HEAD") === git("rev-parse", "origin/main"), + "main is not the same as origin/main. Merge and push first, so the release is what GitHub has.", +); +check(git("tag", "--list", tag) === "", `The tag ${tag} already exists.`); + +say("Checking npm"); +let published = []; +try { + published = JSON.parse(execFileSync("npm", ["view", pkg.name, "versions", "--json"], { encoding: "utf8" })); +} catch { + // Not published yet, or offline; npm publish will say which. +} +check(!published.includes(version), `${pkg.name}@${version} is already on npm.`); +try { + say(` signed in to npm as ${execFileSync("npm", ["whoami"], { encoding: "utf8" }).trim()}`); +} catch { + check(false, "Not signed in to npm. Run npm login."); +} + +run("pnpm", ["typecheck"]); +run("pnpm", ["test"]); +run("pnpm", ["build"]); + +if (dryRun) { + // What would be uploaded. Not npm publish --dry-run: that still asks the + // registry about the old version number, which is already there. + run("npm", ["pack", "--dry-run", "--ignore-scripts"]); + say( + problems.length === 0 + ? `\n✓ Ready to release ${tag}. Run without --dry-run.` + : `\n✗ ${problems.length} thing${problems.length === 1 ? "" : "s"} would stop a real release (marked ! above).`, + ); + process.exit(problems.length === 0 ? 0 : 1); +} + +pkg.version = version; +writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); +git("commit", "--quiet", "-m", `Release ${tag}.`, "--", "package.json"); +git("tag", "-a", tag, "-m", `Release ${tag}.`); +say(`\nCommitted and tagged ${tag}.`); + +// The checks above already ran, so publish skips prepublishOnly. +run("npm", ["publish", "--ignore-scripts"]); + +const pushed = spawnSync("git", ["push", "--quiet", "--atomic", "origin", "main", tag], { + cwd: root, + stdio: "inherit", +}); +if (pushed.status !== 0) { + stop(`Published ${tag} to npm, but could not push. Run: git push --atomic origin main ${tag}`); +} + +say(`\n✓ Released ${tag}.`); +say(" Next:"); +say(` - Release notes: gh release create ${tag} --generate-notes`); +say(" - Site, if it changed: pnpm site:deploy"); +say(" - Upgrade every runner too: npm install -g loopable-cli"); diff --git a/site/index.html b/site/index.html index 36fa8f7..96222de 100644 --- a/site/index.html +++ b/site/index.html @@ -6,12 +6,12 @@ Loopable — your team’s own agent @@ -21,7 +21,7 @@ - + @@ -43,6 +43,12 @@ + + + + + + @@ -84,7 +90,7 @@

A lot of work should not depend on one person.

Loopable is your team’s own agent. Work comes in from Slack, Lark, GitHub, or a - schedule. Codex or Cursor Agent does it on a machine you own, and the answer goes back + schedule. Codex, Claude Code, or Cursor Agent does it on a machine you own, and the answer goes back where it was asked.

@@ -190,7 +196,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

    @@ -226,7 +232,7 @@

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

  • Choose -

    Codex or Cursor Agent, and the model you want.

    +

    Codex, Claude Code, or Cursor Agent, and the model you want.

    Hosted: one vendor.

  • @@ -242,37 +248,46 @@

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

    Loops you can turn on now.

    • - GitHub - Review pull requests I am asked to review - A review, as a comment + Any trigger + Custom loop: your own prompt + Anywhere, or Inbox only +
    • +
    • + Schedule + Do something on a schedule + Inbox, or a Slack, Lark, or GitHub thread
    • GitHub - Plan issues assigned to me - A comment with a plan + Review pull requests + A review, as a comment
    • GitHub - Implement issues assigned to me - A draft pull request + Reply to issues + 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 +
    • +
    • + Gmail + When an email arrives + A reply in the same thread, or anywhere
    -

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

    @@ -333,6 +348,10 @@

    Watch. Run. Write back.

    Codex + + + Claude Code + Cursor Agent @@ -351,7 +370,7 @@

    Watch. Run. Write back.

    - Review or PR + Review or comment @@ -360,7 +379,7 @@

    Watch. Run. Write back.

    Run it

    One computer that stays on.

    -

    A Mac in the office is enough. It needs Node 22+ and Codex or Cursor Agent signed in.

    +

    A Mac in the office is enough. It needs Node 22+ and Codex, Claude Code, or Cursor Agent signed in.

    1. 1 · Start diff --git a/site/src/flow.ts b/site/src/flow.ts index 950c26b..17d9736 100644 --- a/site/src/flow.ts +++ b/site/src/flow.ts @@ -50,7 +50,7 @@ export const beats: Beat[] = [ askVia: "Lark bot", askWho: "Wei", askMsg: "@Loopable why did last night’s refund job fail?", - agent: "Codex", + agent: "Claude Code", runLine: "Querying the ledger service", backKind: "lark", backPlace: "payments-oncall", @@ -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", @@ -117,7 +117,10 @@ function setSource(id: string, kind: Kind, label: string) { } function agentKind(name: string) { - return name.toLowerCase().includes("cursor") ? "cursor" : "codex"; + const lower = name.toLowerCase(); + if (lower.includes("cursor")) return "cursor"; + if (lower.includes("claude")) return "claude"; + return "codex"; } function paintAsk(beat: Beat) { 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..a98284d --- /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: "ClaudeCode", + 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/codex/manifest.ts b/src/agents/codex/manifest.ts index 1813046..c9abf40 100644 --- a/src/agents/codex/manifest.ts +++ b/src/agents/codex/manifest.ts @@ -5,7 +5,7 @@ export const codexManifest = defineAgentManifest({ name: "Codex", tagline: "OpenAI's coding agent, driven non-interactively through codex exec.", docsUrl: "https://developers.openai.com/codex/cli", - icon: "Terminal", + icon: "Codex", accent: "bg-neutral-900 text-white", binaries: ["codex"], installHint: "Install with npm i -g @openai/codex, then run codex login.", diff --git a/src/agents/cursor-agent/manifest.ts b/src/agents/cursor-agent/manifest.ts index fda649d..b892093 100644 --- a/src/agents/cursor-agent/manifest.ts +++ b/src/agents/cursor-agent/manifest.ts @@ -5,7 +5,7 @@ export const cursorAgentManifest = defineAgentManifest({ name: "Cursor Agent", tagline: "Cursor's CLI agent, driven through its print mode.", docsUrl: "https://cursor.com/docs/cli", - icon: "MousePointerClick", + icon: "Cursor", accent: "bg-blue-600 text-white", binaries: ["cursor-agent"], installHint: "Install with curl https://cursor.com/install -fsS | bash, then run cursor-agent login.", 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/components/connection-status.tsx b/src/components/connection-status.tsx index 724c8f4..bb35b7a 100644 --- a/src/components/connection-status.tsx +++ b/src/components/connection-status.tsx @@ -1,17 +1,25 @@ -import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; import type { ConnectionStatus } from "@/lib/domain.ts"; -const LABELS: Record< - ConnectionStatus, - { text: string; variant: "default" | "secondary" | "destructive" | "outline" } -> = { - connected: { text: "Connected", variant: "default" }, - needs_reauth: { text: "Needs reconnecting", variant: "destructive" }, - error: { text: "Error", variant: "destructive" }, - paused: { text: "Paused", variant: "secondary" }, +const LABELS: Record = { + connected: { text: "Connected", dot: "bg-emerald-500" }, + needs_reauth: { text: "Needs reconnecting", dot: "bg-destructive" }, + error: { text: "Error", dot: "bg-destructive" }, + paused: { text: "Paused", dot: "bg-muted-foreground" }, }; +/** A dot and a word: quiet when all is well, red when it is not. */ export function ConnectionStatusBadge({ status }: { status: ConnectionStatus }) { const label = LABELS[status] ?? LABELS.error; - return {label.text}; + return ( + + + {label.text} + + ); } diff --git a/src/components/connector-icon.tsx b/src/components/connector-icon.tsx index b8c7381..be46405 100644 --- a/src/components/connector-icon.tsx +++ b/src/components/connector-icon.tsx @@ -1,54 +1,107 @@ import { Clock, Plug, type LucideIcon } from "lucide-react"; +import { useId } from "react"; import { cn } from "@/lib/utils"; /** - * Each service's own mark, rather than something that resembles what it does. - * An envelope stands for mail in general; the Gmail mark stands for the - * account this connector is signed in to, which is what the icon is doing on - * the page. Drawn as single monochrome paths so they take the accent's - * foreground colour and sit at the same weight beside each other, which is - * what a full-colour logo dropped in next to these would break. + * Each product's own logo, in its own colours, on a plain tile. A service is + * easier to spot by the mark people already know than by a shape drawn in + * the same white on a coloured square as all the others. * - * Paths are the vendors' own marks as published by Simple Icons (CC0). + * Service marks are from Simple Icons (CC0); Codex, Claude Code and Cursor + * from LobeHub Icons (MIT). */ type Mark = (props: { className?: string }) => React.ReactElement; -function mark(viewBox: string, d: string): Mark { - return function Mark({ className }) { - return ( - - - - ); - }; +function GithubMark({ className }: { className?: string }) { + return ( + + + + ); } -const GithubMark = mark( - "0 0 16 16", - "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-2.65-.89-2.65-2.5 0-.77.27-1.4.72-1.9-.07-.18-.31-.9.07-1.87 0 0 .59-.19 1.93.72a6.5 6.5 0 0 1 1.76-.24c.6 0 1.2.08 1.76.24 1.34-.91 1.93-.72 1.93-.72.38.97.14 1.69.07 1.87.45.5.72 1.13.72 1.9 0 1.62-.88 2.3-2.66 2.5.28.24.52.71.52 1.44 0 1.03-.01 1.87-.01 2.13 0 .21.15.46.55.38A7.99 7.99 0 0 0 16 8c0-4.42-3.58-8-8-8Z", -); +function GmailMark({ className }: { className?: string }) { + return ( + + + + ); +} -const GmailMark = mark( - "0 0 24 24", - "M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-.904.732-1.636 1.636-1.636h.749L12 10.907l9.615-7.086h.749c.904 0 1.636.732 1.636 1.636Z", -); +function WechatMark({ className }: { className?: string }) { + return ( + + + + ); +} -const WechatMark = mark( - "0 0 24 24", - "M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 4.882-2.235 7.621-1.552-.983-3.29-4.269-5.653-8.163-5.653zm-2.877 3.63a1.09 1.09 0 0 1 1.089 1.089 1.09 1.09 0 0 1-1.089 1.09 1.09 1.09 0 0 1-1.089-1.09 1.09 1.09 0 0 1 1.09-1.089zm5.807 0a1.09 1.09 0 0 1 1.089 1.089 1.09 1.09 0 0 1-1.09 1.09 1.09 1.09 0 0 1-1.088-1.09 1.09 1.09 0 0 1 1.089-1.089zm5.463 3.573C13.008 9.39 9.9 12.06 9.9 15.354c0 1.874 1.017 3.582 2.67 4.834a.492.492 0 0 1 .177.554l-.324 1.235c-.016.058-.04.117-.04.177 0 .136.108.246.242.246a.271.271 0 0 0 .14-.045l1.586-.928a.72.72 0 0 1 .597-.082c.786.226 1.633.336 2.517.336 4 0 7.245-2.741 7.245-6.122 0-3.38-3.245-6.121-7.245-6.121zm-2.203 3.026a.908.908 0 0 1 .907.907.908.908 0 0 1-.907.908.908.908 0 0 1-.908-.908.908.908 0 0 1 .908-.907zm4.839 0a.908.908 0 0 1 .907.907.908.908 0 0 1-.907.908.908.908 0 0 1-.908-.908.908.908 0 0 1 .908-.907z", -); +/** Slack's four colours, one to a quarter of the same mark. */ +function SlackMark({ className }: { className?: string }) { + const id = useId(); + const quarters = [ + { x: 0, y: 0, fill: "#36C5F0" }, + { x: 12, y: 0, fill: "#2EB67D" }, + { x: 12, y: 12, fill: "#ECB22E" }, + { x: 0, y: 12, fill: "#E01E5A" }, + ]; + return ( + + + {quarters.map((q, index) => ( + + + + ))} + + {quarters.map((q, index) => ( + + ))} + + ); +} -const SlackMark = mark( - "0 0 24 24", - "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z", -); +const SLACK = + "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"; function FeishuMark({ className }: { className?: string }) { return ( - - - - + + + + + + ); +} + +function CodexMark({ className }: { className?: string }) { + const id = useId(); + return ( + + + + + + + + + + + ); +} + +function ClaudeCodeMark({ className }: { className?: string }) { + return ( + + + + ); +} + +function CursorMark({ className }: { className?: string }) { + return ( + + ); } @@ -58,37 +111,54 @@ function FeishuMark({ className }: { className?: string }) { * the client bundle. Names resolve here, explicitly, to keep the bundle from * pulling in a whole icon set. */ -const ICONS: Record = { +const MARKS: Record = { Github: GithubMark, Slack: SlackMark, Feishu: FeishuMark, Gmail: GmailMark, Wechat: WechatMark, - // Not a service's mark: a clock is the thing itself rather than a stand-in - // for somewhere you have an account. - Clock, - Plug, + Codex: CodexMark, + ClaudeCode: ClaudeCodeMark, + Cursor: CursorMark, }; +/** Not products: a clock is the thing itself, and a plug is the fallback. */ +const GLYPHS: Record = { Clock, Plug }; + +/** A product's logo on a tile. `accent` is no longer drawn; manifests still carry it. */ export function ConnectorIcon({ icon, - accent, className, }: { icon: string; - accent: string; + accent?: string; className?: string; }) { - const Icon = ICONS[icon] ?? Plug; + const Mark = MARKS[icon]; + if (Mark) { + return ( + + + + ); + } + const Glyph = GLYPHS[icon] ?? Plug; return ( - + ); } + +/** The same tile, for an agent. */ +export const ProductIcon = ConnectorIcon; diff --git a/src/components/loop-form.tsx b/src/components/loop-form.tsx index 39eefda..7538b0f 100644 --- a/src/components/loop-form.tsx +++ b/src/components/loop-form.tsx @@ -1,13 +1,12 @@ 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, @@ -15,15 +14,17 @@ import { 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 { - CONNECTOR_MANIFESTS, connectorAction, connectorManifest, connectorWorkflow, + isInboxOnly, + writerName, + writers, } 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,62 +32,66 @@ 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(60_000), label: "Every minute" }, { 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" }, { value: String(4 * 60 * 60_000), label: "Every 4 hours" }, ]; -/** Connectors that can write at all, for the question of where the answer goes. */ -const WRITERS = CONNECTOR_MANIFESTS.filter((entry) => entry.actions.length > 0); +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 trigger + +
      + {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 }) { +export function LoopForm({ loop, trigger }: { loop: LoopEdit; trigger?: React.ReactNode }) { const navigate = useNavigate(); const fresh = loop.id === null; const connector = connectorManifest(loop.connectorId); @@ -94,9 +99,11 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { const [name, setName] = useState(loop.name); const [prompt, setPrompt] = useState(loop.prompt); - const [guidance, setGuidance] = useState(loop.guidance ?? ""); + const [folder, setFolder] = useState(() => { + const saved = typeof loop.settings.folder === "string" ? loop.settings.folder : ""; + return saved.startsWith("~/") ? saved.slice(2) : saved; + }); const [agentId, setAgentId] = useState(loop.agentId ?? DEFAULT_AGENT); - const [enabled, setEnabled] = useState(loop.enabled); const [settings, setSettings] = useState(() => initialFieldValues(workflow?.settings ?? [], loop.settings), ); @@ -110,12 +117,13 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { ), ); const [saving, setSaving] = useState(false); + // A custom loop starts with no prompt, so there is nothing to say it came from. + const [written] = useState(loop.prompt.trim() !== ""); 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 = (connectorId: string, id: string) => { setActionConnectorId(connectorId); setActionId(id); setActionTarget(initialFieldValues(connectorAction(connectorId, id)?.target ?? [], {})); @@ -126,9 +134,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. ); @@ -144,18 +151,19 @@ export function LoopForm({ loop }: { loop: LoopEdit }) { connectorId: loop.connectorId, workflowId: loop.workflowId, prompt, - guidance: guidance.trim() || null, + guidance: null, agentId: agentId === DEFAULT_AGENT ? null : agentId, - settings, + settings: workflow.runsIn === "folder" ? { ...settings, folder } : settings, actionConnectorId, 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({ to: "/loops/$loopId", params: { loopId: saved.id } }); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } finally { @@ -163,230 +171,33 @@ 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." - : "." - }` - : `${connector?.name ?? loop.connectorId} sends this over as it happens, and Loopable picks it up within a couple of minutes.` - } - /> - - - - - - {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} + const crosses = action && actionConnectorId !== loop.connectorId; + const flattens = action && workflow.answer === "review" && !action.accepts.includes("review"); - {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.`} -

      -