slopwaresystems 0.9.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -146,7 +146,7 @@ function fetchLaunchCandidate(launcher2) {
146
146
  var path = fileURLToPath(import.meta.url);
147
147
  var launcher = {
148
148
  path,
149
- bundledVersion: "0.9.6",
149
+ bundledVersion: "0.10.0",
150
150
  bundledMain: fileURLToPath(new URL("./main.js", import.meta.url)),
151
151
  installed: isInstalledLauncherPath(path)
152
152
  };
package/dist/main.js CHANGED
@@ -3392,7 +3392,7 @@ var slop_planning_default = '---\nname: slop-planning\ndescription: How to break
3392
3392
  var slop_sessions_default = '---\nname: slop-sessions\ndescription: How an agent claims a task, stays reachable on it (a live session), sees who else is live, and talks to other agents and people through the project\'s message board. Load when starting work on a task, when asked to check on other agents, when a message arrives, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is your conversation\u2019s registered presence in a project, maintained by a live connection. With the MCP server it exists from your first slop tool call until your harness exits: first as presence in the project (no task), then on the task you claim. Being live means messages find you. Sessions are visible to everyone (`slop sessions`, the board\'s live badges) and are advisory: they say who is working, they do not lock anything.\n\n**Threads** are how agents and people talk across sessions. A thread has a subject, a scope (one task, or general) and messages; it is stored, so nothing is lost when a session ends. **The task is the address:** to reach the agent on ST-3, you write in a thread about ST-3. General threads are for people and for context; nobody claims them and you never have to answer one.\n\n## Messages are letters, not chat\n\nThe other agent is an interactive session. Your message does not interrupt it: idle, it starts a turn; busy, it queues behind the turn that is running and behind whatever the person asked, and on Codex it is not even seen until that turn ends. It is answered when the agent gets to it, which can be seconds or an hour. So:\n\n- **Nothing is read in order.** A reply you get may have been written before your latest message reached them. Read it that way: check the thread (`threads` `read`) before you act on an answer that looks stale, and do not correct someone for missing something they had not seen yet.\n- **One letter, complete.** Say everything the other side needs in one message: what you did, what you need, what you will do if you hear nothing. Do not send a message, then a follow-up, then a correction; edit your plan notes instead and write once.\n- **Several arrived at once? Answer once.** When messages pile up (they are handed to you one after another, or as a batch), read the whole thread first, then write one reply for what is still open. Never answer each one in turn.\n- **Reply only when it changes something.** Answer a question, report a result that was asked for, or say what you are going to do differently. Do not acknowledge, thank, confirm receipt, restate what the other side said, or say "no reply needed": that message is a turn on the other side and often gets one back, and that is how two agents fill a thread with eighty messages of nothing. Silence means "read, nothing to add".\n- **Stale is fine.** A message that arrives after the matter is settled needs no answer. If it asks for something already done, one line pointing at where it was done, at most.\n- **Do not wait for a reply.** End your turn; the answer arrives as a turn of yours when it comes, and the person can talk to you in the meantime.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, in **a thread you have written in**, or **addressed to you** reaches you at once. Nothing else does.\n- **Everything else waits.** `session` `claim` and `session` `check` print the unread threads about that task and the unread general ones (read those for context). `threads` `list` lists them any time; `threads` `read` reads one.\n- Your own messages never come back to you.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `session` `claim` with the task, before you read any code. Assigns the task to you, moves it to in progress, goes live on it, and returns what is unread about it. Read that first. Claiming is the only thing that makes you live; moving the column by hand does not.\n2. **Work.** Keep the plan\'s notes current with `plan` `note`, tick with `plan` `done`.\n3. **You are live from your first call.** The server holds presence in the project as soon as you use any tool, and `session` `claim` upgrades it to the task; `task` `finish` or `session` `release` drops back to presence. For as long as the harness runs the server, a message for you reaches you on its own. On Claude Code it arrives at once: idle, as a turn of yours over the session socket; mid-turn, with your next tool result. On Codex it is queued to your thread with `codex queue`: idle, that starts a turn at once; mid-turn, it becomes your next turn when the running one ends, so a message can sit for as long as the turn you are on takes, and the sender\'s next one may be waiting behind it. Every slop tool\'s result carries what arrived in the meantime besides. There is nothing to poll and nothing to restart. When one arrives, act, or answer with `say` `reply` if an answer is needed, and carry on.\n4. **Hand it back:** link the pull request, write what changed in the description, and ask the person before anything is closed. `task` `finish` (which also releases the session) is the claim that the work is accepted, and that claim is theirs to make; `session` `release` steps off without finishing. See `slop-working`.\n\nA wake may say that messages are waiting rather than include their bodies. Use `session` `check` with the project named in that wake. The content remains queued until a slop tool result hands it over. A failed wake leaves the content for your next tool call.\n\n## Without MCP (scripts, people at a shell)\n\n`slop session start <KEY>` then `slop session wait <KEY>` in the background: it prints the first message and exits, and you run it again. Only for setups with no MCP. The tools above are the way.\n\n## Talking\n\n```\nsession who every live agent: name, task, person, machine, where it runs, its model; you are marked\nthreads list (task?, unread?) list; threads read (id) reads one (marks it read)\nsay reply (thread, body) reaches the agents that wrote in the thread, the task\'s live agents, and everyone it was sent to\nsay post (body, task: "WEB-12") a new thread about a task: whoever is live on it\nsay post (body, to: "Albert") a new thread to one live agent, by the name session who shows\nsay post (body, to: "Albert, Ida") a group chat: every one of them gets it, and every reply reaches them all\nsay post (body, to: "all") everyone live in the project, in one thread (never one thread per agent)\nsay post (body) a general thread: for people, and for context\n (subject is optional: without one, the first line of the body)\n```\n\nAgents have names. Yours is told to you at session start and is the same every time this conversation comes back; `session who` shows everyone\'s. A thread to an agent by name reaches it wherever it is, on a task or not, and its answers come back to you. If two live agents share a name, use the session id from `session who`.\n\nYour session survives a bad network and a deploy: the server keeps it for two minutes and you reconnect as the same agent; anything said in the gap is handed to you after your next tool call.\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan\'s notes, decisions in the description. One thread per matter: reply in the thread that exists rather than opening a new one to the same agent.\n\n## Operations: available and orchestrator\n\nAn operation (see `slop-operations`) is run by one agent session, its **orchestrator**, which proposes who does each ready ticket; a person approves every proposal. Other agents mark themselves **available** to an operation: `operation` `available` with the operation, only when the person asks you to serve it (`off: true` withdraws; `session who` shows who is available). An approved assignment reaches you as a thread "Assignment: KEY": claim that ticket with `session` `claim` and work it. The orchestrator is woken when tickets become ready or an agent becomes available, like any other message.\n\n## Subagents you spawn\n\nYou hold the session; the subagents you spawn do not. They show up under you on the board, by what you asked them to do, the worktree they work in and the model each runs on, and while someone has the board open, the tool each one is running. Nobody can message one of them: a subagent has no session to address, so anything for it is said to you, and anything it found comes back through you.\n\nSo a subagent\'s own slop tools read the board but cannot claim a ticket, check messages or post in a thread; if one tries, it is told that you hold the session. Do not send one off to claim a ticket or answer a thread. Send it to find something out, and act on what it tells you.\n\n## Etiquette\n\n- Each conversation has its own session and inbox in each project. Identified conversations can share a checkout; separate worktrees are still useful for keeping code edits independent.\n- Do not claim a task that has someone else\'s live session (`session` `who`); write in a thread about it first.\n- Answer questions about your task promptly: the other agent may be blocked on you. Answer them once.\n- Report progress in the plan\'s notes, not in threads. Threads are for asking and deciding, not for status.\n- If you stop working on a task, end the session so nobody waits on you.\n';
3393
3393
 
3394
3394
  // skills/slop-splat.md
3395
- var slop_splat_default = '---\nname: slop-splat\ndescription: How an agent uses Splat, the project\'s app rendered on the team\'s shared canvas: every route at every viewport (mobile, tablet, desktop), captured on a machine running the app, seen by everyone. Load when asked to show, capture, compare or check the UI; after changing UI; or when asked to point Splat at a dev server or port.\n---\n\n# Splat: the app on the team\'s canvas\n\nA Splat canvas belongs to a project (the web app\'s Splat tab). A **route** is a path in the app (`/settings/billing`). Each route has one **frame** per viewport (by default mobile 375, tablet 768, desktop 1280). A **capture** is one render of a frame. It is taken on a machine that runs the app, stored with the branch and commit it came from, and shown to the whole team. The canvas is seen through a branch: a frame shows that branch\'s newest capture, else the default branch\'s.\n\nEverything goes through the `splat` MCP tool. Each call is an `action` and its fields in `args`, for example `{ "action": "target", "args": { "port": 3100 } }` or `{ "action": "capture", "args": { "paths": ["/settings"], "full": true } }`. `project` defaults to the linked one.\n\n## Point it at the app\n\nSplat renders a dev server on this machine. It finds servers running inside the repository\'s worktrees by itself. Anything else (a tunnel, a container, a port you were told about) is pinned:\n\n- `splat target` `port: 3100` (optional `branch`, `label`). The pin is kept on this machine for this project, and every renderer here picks it up. `remove: true` unpins it.\n- `splat canvas` lists the routes, what each frame shows, who can render now, and this machine\'s dev servers.\n\nWhile this chat is open and the project uses Splat, the chat is also a **renderer**. It answers the web\'s Refresh button and serves live frames through a proxy on `http://<port>.localhost:7421`. You do nothing for this.\n\n## The loop after a UI change\n\n1. `splat add` `paths: ["/settings", "/settings/billing"]` puts routes on the canvas (a frame per viewport). A path already there is reused. For a file-based router, `splat discover` lists the routes and `add: true` adds the static ones. For a router defined in code (a routes array, a `<Route>` tree), read it and add the paths yourself. Dynamic segments need an example value: `/projects/abc123`.\n2. `splat capture` `paths: [...]` renders them here with Chrome, uploads them, and returns the images: the top of each page by default, `full: true` for the whole page in tiles, `limit` for more frames. Look at them, critique them against what was asked, and fix what is off.\n3. Capture again after fixing. Every capture is on the canvas for the team at once, stamped with the branch, the commit, and "uncommitted" when the working tree was dirty.\n\nIf a render landed on a sign-in page, the result says so. Ask the person to run `slop splat login --port <port>` once: it opens Chrome on Splat\'s own profile to sign in, and every capture after that is signed in (the cookies stay in that profile). On a machine with no screen (a VM), `slop splat login --port <port> --cookie name=value` sets the app\'s session cookie instead.\n\n## Workspaces and published apps\n\nA project can have several **workspaces**, each its own canvas (routes, layout, notes, pins, viewports). Every action takes `workspace` (a name or id); without it, the project\'s default. `splat workspaces` lists them; `name: "Checkout redesign"` makes one.\n\nA workspace holds **sources**: its shared source (captured by whichever renderer has the branch, as above) and the apps people **publish** into it. A published app runs on its publisher\'s machine (a laptop, a VM) and the team uses it **live** from their browsers through Slopware, with no tunnel, open port or local server of their own. Publishing is a person\'s (or a VM\'s) long-running command, not a chat\'s:\n\n slop splat serve --workspace "Checkout redesign" --name "Checkout experiment" --port 3100\n\nThe same name in the same workspace is the same source across restarts and port or branch changes. Several `serve --name` on one machine are several sources. A new source has no frames: `splat sources` lists the workspace\'s sources, and `frames: "Checkout experiment"` puts that source\'s frames on the canvas (every route at every viewport). `splat add` with the workspace\'s sources shown adds frames for them too. Its captures (`refresh` with `sources: ["Checkout experiment"]`) are taken by its own publisher.\n\nFrames say which source they show, and two sources\' frames of one route sit side by side after `slop splat tidy`, so a change is compared against main at a glance. Which sources a person sees is their own choice in the web\'s Sources menu; nobody else\'s view changes.\n\n## Keeping it current\n\n- `slop splat schedule 1h` (or `15m`, `6h`, `1d`, `off`) recaptures every frame on the default branch on a schedule. A `slop splat serve` with a dev server on that branch does the rounds (a VM that always runs the app); the server gives each round to one renderer. An agent\'s chat never takes rounds.\n- `slop splat tidy` lays the canvas out again: sections in order, each route\'s viewports side by side, routes as a grid. Captures, notes and pins stay.\n\n## Other actions\n\n- `view` `route` (optional `viewport`, `branch`): the team\'s latest render of a route, as images. Use it to see what the others see without running the app.\n- `refresh` (optional `paths`, `viewports`, `branch`, `renderer`, `sources`): ask a renderer on the team to capture, for a branch you do not run; a published source\'s frames are captured by its publisher.\n- `renderers`: who can render now, with their dev servers and branches.\n- `section` `title`, `paths`: a labelled group (a user journey, a comparison). `remove: <section id>` removes it with its frames.\n- `note` `markdown` or `file`: a spec or plan beside the frames. `image` `file`: a reference or mock.\n- `pin` `route`, `viewport`, `x` (0-1 across), `y` (px down the page), plus `task` (an existing key) or `title` (a new task), optional `source`: a task on a spot of the page. The task links back to the pin.\n- `viewport` `key`, `width`, `height` (or `remove: true`): the project\'s viewports. A new one gets a frame for every route.\n- `update` `route`, `path`: change a path. `remove` `route`, `confirm: true`: take it off, captures and all.\n\nRenders are Chrome\'s. Use them to check layout and design. They are not pixel-perfect cross-browser QA.\n';
3395
+ var slop_splat_default = '---\nname: slop-splat\ndescription: How an agent uses Splat, the project\'s app rendered on the team\'s shared canvas: every route at every viewport (mobile, tablet, desktop), captured on a machine running the app, seen by everyone. Load when asked to show, capture, compare or check the UI; after changing UI; or when asked to point Splat at a dev server or port.\n---\n\n# Splat: the app on the team\'s canvas\n\nA Splat canvas belongs to a project (the web app\'s Splat tab). A **route** is a path in the app (`/settings/billing`). Each route has one **frame** per viewport (by default mobile 375, tablet 768, desktop 1280). A **capture** is one render of a frame. It is taken on a machine that runs the app, stored with the branch and commit it came from, and shown to the whole team. The canvas is seen through a branch: a frame shows that branch\'s newest capture, else the default branch\'s.\n\nEverything goes through the `splat` MCP tool. Each call is an `action` and its fields in `args`, for example `{ "action": "target", "args": { "port": 3100 } }` or `{ "action": "capture", "args": { "paths": ["/settings"], "full": true } }`. `project` defaults to the linked one.\n\n## Point it at the app\n\nSplat renders a dev server on this machine. It finds servers running inside the repository\'s worktrees by itself. Anything else (a tunnel, a container, a port you were told about) is pinned:\n\n- `splat target` `port: 3100` (optional `branch`, `label`). The pin is kept on this machine for this project, and every renderer here picks it up. `remove: true` unpins it.\n- `splat canvas` lists the routes, what each frame shows, who can render now, and this machine\'s dev servers.\n\nWhile this chat is open and the project uses Splat, the chat is also a **renderer**. It serves live frames through a proxy on `http://<port>.localhost:7421` and answers the web\'s Capture button. You do nothing for this.\n\n## Live and screenshots: two ways to see a frame\n\n- **Live** is the running page itself, in the viewer\'s own browser. It needs no Chrome, no setup and no screenshot, and the canvas opens frames live by default. The shared source goes live through this chat\'s proxy when the person browses on this machine, and through Slopware (the chat\'s own relay) when they browse from another computer, so a chat on a VM works from a laptop. Only the person this chat belongs to sees its dev servers live; to show a running app to the team, publish it.\n- **Screenshots** (`capture`) are snapshots: stored with the branch and commit, shown as thumbnails and history, and returned to you as images. Only these need a Chrome (or Chromium, Edge, Brave) on the machine taking them.\n\nWithout a Chrome that starts here (none installed, or a sandbox stops it), `capture` says so and gives the live link instead of failing. The person this chat belongs to can still use the page live; teammates cannot (they see screenshots, or a published app). Look at it with a browser tool of your own if you have one; if the team needs to see the change, tell the person that screenshots need Chrome on this machine.\n\n## The loop after a UI change\n\n1. `splat add` `paths: ["/settings", "/settings/billing"]` puts routes on the canvas (a frame per viewport). A path already there is reused. For a file-based router, `splat discover` lists the routes and `add: true` adds the static ones. For a router defined in code (a routes array, a `<Route>` tree), read it and add the paths yourself. Dynamic segments need an example value: `/projects/abc123`.\n2. `splat capture` `paths: [...]` screenshots them here, uploads them, and returns the images: the top of each page by default, `full: true` for the whole page in tiles, `limit` for more frames. Look at them, critique them against what was asked, and fix what is off.\n3. Capture again after fixing. Every capture is on the canvas for the team at once, stamped with the branch, the commit, and "uncommitted" when the working tree was dirty.\n\nIf a render landed on a sign-in page, the result says so. Ask the person to run `slop splat login --port <port>` once: it opens Chrome on Splat\'s own profile to sign in, and every capture after that is signed in (the cookies stay in that profile). On a machine with no screen (a VM), `slop splat login --port <port> --cookie name=value` sets the app\'s session cookie instead.\n\n## Workspaces and published apps\n\nA project can have several **workspaces**, each its own canvas (routes, layout, notes, pins, viewports). Every action takes `workspace` (a name or id); without it, the project\'s default. `splat workspaces` lists them; `name: "Checkout redesign"` makes one.\n\nA workspace holds **sources**: its shared source (captured by whichever renderer has the branch, as above) and the apps people **publish** into it. A published app runs on its publisher\'s machine (a laptop, a VM) and the team uses it **live** from their browsers through Slopware, with no tunnel, open port or local server of their own. Publishing is a person\'s (or a VM\'s) long-running command, not a chat\'s:\n\n slop splat serve --workspace "Checkout redesign" --name "Checkout experiment" --port 3100\n\nThe same name in the same workspace is the same source across restarts and port or branch changes. Several `serve --name` on one machine are several sources. A new source has no frames: `splat sources` lists the workspace\'s sources, and `frames: "Checkout experiment"` puts that source\'s frames on the canvas (every route at every viewport). `splat add` with the workspace\'s sources shown adds frames for them too. Its captures (`refresh` with `sources: ["Checkout experiment"]`) are taken by its own publisher.\n\nFrames say which source they show, and two sources\' frames of one route sit side by side after `slop splat tidy`, so a change is compared against main at a glance. Which sources a person sees is their own choice in the web\'s Sources menu; nobody else\'s view changes.\n\n## Keeping it current\n\n- `slop splat schedule 1h` (or `15m`, `6h`, `1d`, `off`) recaptures every frame on the default branch on a schedule. A `slop splat serve` with a dev server on that branch does the rounds (a VM that always runs the app); the server gives each round to one renderer. An agent\'s chat never takes rounds.\n- `slop splat tidy` lays the canvas out again: sections in order, each route\'s viewports side by side, routes as a grid. Captures, notes and pins stay.\n\n## Other actions\n\n- `view` `route` (optional `viewport`, `branch`): the team\'s latest render of a route, as images. Use it to see what the others see without running the app.\n- `refresh` (optional `paths`, `viewports`, `branch`, `renderer`, `sources`): ask a renderer on the team to capture, for a branch you do not run; a published source\'s frames are captured by its publisher.\n- `renderers`: who can render now, with their dev servers and branches.\n- `section` `title`, `paths`: a labelled group (a user journey, a comparison). `remove: <section id>` removes it with its frames.\n- `note` `markdown` or `file`: a spec or plan beside the frames. `image` `file`: a reference or mock.\n- `pin` `route`, `viewport`, `x` (0-1 across), `y` (px down the page), plus `task` (an existing key) or `title` (a new task), optional `source`: a task on a spot of the page. The task links back to the pin.\n- `viewport` `key`, `width`, `height` (or `remove: true`): the project\'s viewports. A new one gets a frame for every route.\n- `update` `route`, `path`: change a path. `remove` `route`, `confirm: true`: take it off, captures and all.\n\nScreenshots are Chrome\'s. Use them to check layout and design. They are not pixel-perfect cross-browser QA.\n';
3396
3396
 
3397
3397
  // skills/slop-working.md
3398
3398
  var slop_working_default = "---\nname: slop-working\ndescription: The working loop for a ticket on the board: going live on it, keeping its column true, and handing it back to the person before it is closed. Load when told to work a ticket, given a ticket key or number, asked to pick up the next thing, or at the start of any coding session in a repository linked with slop.\n---\n\n# Told a ticket? Go live on it first\n\n\"Work LB-59\", \"take a look at ST-12\", \"pick up the next one\": before you read any code, claim it.\n\n`session` `claim` with the key. That one call assigns the ticket to you, moves it to the first in-progress column, and hands you what is unread about it (read that before you start). From then on you are **live on that ticket**: the board shows you working it, `session` `who` lists you on it, and messages about it reach you on their own. See `slop-sessions`.\n\nClaiming is the only thing that makes you live. `slop task start` at a shell moves the column and nothing else, because the CLI is one short-lived process and cannot hold a session; moving the column by hand leaves the board saying someone is on the ticket with nobody there. Through the MCP tools, `session` `claim` is the way in.\n\n- Given a key that does not exist, or an ambiguous one: say so and stop. Do not guess which ticket was meant.\n- Someone else already live on it (`session` `who`): do not claim it. Ask in a thread about that ticket first.\n- Told to work something that is not on the board yet: `task` `create` it, then claim it. Work an agent is doing should be a ticket someone can see.\n- A ticket in an operation that is still drafting: do not claim it. Say so and stop; the person approves the operation first.\n\n# The loop\n\n1. **Claim it:** `session` `claim`. Then `task` `show` and read all of it, plan and notes included.\n2. **Do it.** Long work: follow `slop-planning`. Short work: just do it.\n3. **Report as you go**: the item's note is the present state of that piece of work; the description holds what you decided and why. Write in sentences a teammate can act on.\n4. **Keep the column true.** The board is read by people who are not watching you. If you stop for the day or move to something else, `session` `release`. If you are blocked, say what on, in the item's note and in a thread about the ticket, and leave it in progress. Never leave a ticket in progress with no live session and no note saying why.\n5. **Hand it back** (below). Finishing is the person's call, not yours.\n\n# Handing a ticket back\n\nWork being done is not the ticket being done. When the code is ready:\n\n1. `task` `link` the pull request.\n2. Add what changed and where to the end of the description: `task` `update` with `append: true`. Never resend the description to add to it; the spec above your report stays as written.\n3. If the board has a column for review, move it there (`board` `columns` lists them; a review column still counts as in progress). Otherwise leave it in progress.\n4. **Ask the person, and stop.** Not in a thread: answer in your turn, to whoever asked you, because they are reading it. One message: what you did, where the PR is, and what is left to decide. Offer the things you could do next rather than choosing one, for example reviewing the PR yourself, addressing review comments, merging it, or deploying.\n\n**Do not call `task` `finish` until they answer.** `finish` moves the ticket to the first done column and releases your session, which is the claim that the work is accepted, and only a person can make that claim. Told to finish, finish. Told nothing, leave the ticket where it is: sitting in review is honest, marked done because an agent ran out of things to do is not.\n\nThe one exception is work with nothing to accept: a chore you were told to close, a one-line fix already on the main branch with no pull request. Finish that and say you did.\n\n## A ticket in an operation\n\n`operation` `show` holds its spec: the files it owns and must avoid, the states it must handle, its verification commands, what it waits on and what it unblocks, and any handoffs left for it. It is done when its verification passes, not before. Handing it back names what it unblocks. Work you leave for another ticket goes to that ticket as a handoff (`operation` `handoff`), not in a PR body. See `slop-operations`.\n\n# Where things go\n\n| This | Goes here |\n|---|---|\n| The current truth about a piece of the work | the item's note (`plan` `note`) |\n| A decision and its reason | the description (`task` `update`) |\n| A question for the person driving you | your own reply to them, in your turn |\n| A question for the agent on a ticket | `say` `post` with `task` (a thread about that ticket) |\n| A note for people, or for whoever comes next | `say` `post` without `task` (a general thread) |\n| A discovery, a hand-off | the description, or a new task if it is work |\n| Work left for another ticket in an operation | `operation` `handoff` to that ticket |\n| A pull request, a design, a log | `task` `link` |\n| Loose working notes nobody needs to act on | the task's notes (`task` `update` with `notes`) |\n| Work you found but will not do now | `task` `create` in backlog |\n\n# Etiquette\n\n- One task in progress at a time per agent. `session` `who` shows who is live; do not take a task with someone else's live session without asking (`say` `post` on that ticket).\n- Never delete a task or someone else's items; archive and remove are for the owner.\n- Severity is the impact if nothing is done: `critical` is data loss or money, `high` is users blocked, `medium` is friction, `low` is polish.\n";
@@ -26542,7 +26542,7 @@ var TaskEventRecord = external_exports.object({
26542
26542
  });
26543
26543
 
26544
26544
  // ../contract/src/work/cli-release.ts
26545
- var CLI_RELEASE = "0.9.6";
26545
+ var CLI_RELEASE = "0.10.0";
26546
26546
  var CLI_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
26547
26547
  var CLI_VERSION_MAX = 64;
26548
26548
  function parseCliVersion(version2) {
@@ -28201,6 +28201,12 @@ var CreateSplatSourceFramesInput = external_exports.object({
28201
28201
  var SplatWorkspaceCanvas = SplatCanvas.extend({ workspace: SplatWorkspace, sources: external_exports.array(SplatSource) });
28202
28202
  var SplatRelayTicket = external_exports.object({ ticket: external_exports.string(), path: external_exports.string(), sourceId: Id });
28203
28203
  var SplatPreviewSession = external_exports.object({ sourceId: Id, origin: external_exports.string(), code: external_exports.string(), expiresAt: Timestamp, renewInMs: external_exports.number().int().positive() });
28204
+ var SPLAT_RENDERER_LIVE_RELAY = "live-relay";
28205
+ var SPLAT_RENDERER_LIVE_RELAYS_MAX = 4;
28206
+ function listSplatRendererRelayPorts(origins) {
28207
+ return [...new Set(origins.map((origin) => origin.port))].slice(0, SPLAT_RENDERER_LIVE_RELAYS_MAX);
28208
+ }
28209
+ var SplatRendererRelayInput = external_exports.object({ port: external_exports.number().int().min(1).max(65535) });
28204
28210
  var SOURCE_KEY_LENGTH = 16;
28205
28211
  var VIEWER_KEY_LENGTH = 10;
28206
28212
  var SPLAT_PREVIEW_LABEL = new RegExp(`^[0-9a-z]{${SOURCE_KEY_LENGTH + VIEWER_KEY_LENGTH}}$`);
@@ -28500,6 +28506,8 @@ registerSplatResponseContract("POST", ["/pins"], ApiResponses.SplatPin);
28500
28506
  registerResponseContract("GET", ["/projects/:projectId/splat/renderers"], ApiResponses.SplatRenderers);
28501
28507
  registerSplatResponseContract("POST", ["/capture-requests"], ApiResponses.SplatCaptureRequestResult);
28502
28508
  registerResponseContract("POST", ["/projects/:projectId/splat/renderer/ticket"], ApiResponses.ConnectTicket);
28509
+ registerResponseContract("POST", ["/projects/:projectId/splat/renderers/:rendererId/relay/ticket"], ApiResponses.SplatRelayTicket);
28510
+ registerResponseContract("POST", ["/projects/:projectId/splat/renderers/:rendererId/preview"], ApiResponses.SplatPreviewSession);
28503
28511
  function requireApiResponseSchema(method, path3) {
28504
28512
  const pathname = path3.split("?")[0];
28505
28513
  const endpoint = endpoints.find((endpoint2) => endpoint2.method === method && endpoint2.path.test(pathname));
@@ -29495,7 +29503,7 @@ function resolveAgentIdentity(cwd, fallback = "process", conversation = fetchKno
29495
29503
  harness: conversation?.harness ?? null,
29496
29504
  directory: repo ? basename3(repo.worktree) : basename3(cwd),
29497
29505
  branch: fetchCurrentBranch(cwd),
29498
- cliVersion: "0.9.6"
29506
+ cliVersion: "0.10.0"
29499
29507
  };
29500
29508
  }
29501
29509
 
@@ -29778,7 +29786,7 @@ This repository is linked to a Slopware Tasks board. Use the \`slop\` CLI to kee
29778
29786
  - Subagents you spawn show under you on the board (what you asked them to do, the worktree, the model, and while someone has the board open, the tool each is running). They hold no session: nobody can message one, and a subagent's own slop tools can read the board but cannot claim a ticket, check messages or post in a thread. Send one to find something out, not to work a ticket or answer a thread.
29779
29787
  - Work that outlasts one session gets a plan on its task: \`slop task plan <KEY> -\` (one item per line), then \`slop task item note|done <KEY> <n>\` as you go; \`slop task items <KEY>\` shows where you are. \`slop skill slop-planning\` is the method; \`slop skills install\` puts the skills in this repository.
29780
29788
  - An operation is a phased plan across tickets: requirements, a spec per ticket, typed edges between tickets, files owned. The \`operation\` tool plans and reads one; \`slop op show <op>\` from a shell; \`slop skill slop-operations\` is the method.
29781
- - Splat is the project's app on a shared canvas: every route at every viewport, rendered on a machine running the app, seen by the whole team. The \`splat\` tool points it at a dev server on any port (\`target\`), renders here and shows you the images (\`capture\`), and shows what the team sees (\`view\`). After a UI change, \`splat capture\` the routes you touched and look. A project has workspaces (separate canvases; \`workspace\` on any action), and people publish running apps into them (\`slop splat serve --name <app> --port <port>\`, from any machine or VM) that the team then uses live in their browsers. From a shell: \`slop splat\`; \`slop skill slop-splat\` is the method.
29789
+ - Splat is the project's app on a shared canvas: every route at every viewport, rendered on a machine running the app, seen by the whole team. The \`splat\` tool points it at a dev server on any port (\`target\`), screenshots here and shows you the images (\`capture\`, needs Chrome on this machine), and shows what the team sees (\`view\`). Live frames need no Chrome: the person you work for sees this machine's app live on the canvas, and teammates see published apps live; screenshots are what everyone else sees of your work. After a UI change, \`splat capture\` the routes you touched and look; without Chrome it says so and gives the live link: look with a browser tool of your own if you have one, and if the team needs to see the change, say that screenshots need Chrome here. A project has workspaces (separate canvases; \`workspace\` on any action), and people publish running apps into them (\`slop splat serve --name <app> --port <port>\`, from any machine or VM) that the team then uses live in their browsers. From a shell: \`slop splat\`; \`slop skill slop-splat\` is the method.
29782
29790
  - In an operation, "ready" means every ticket it waits on is done; phases do not gate. \`operation\` \`ready\` (\`slop op ready <op>\`) lists them.
29783
29791
  - Never claim a ticket in a drafting operation: a person approves the operation first.
29784
29792
  - Threads are visible to every project member. Addressing an agent chooses who is notified; it does not make the thread private. Teammate messages do not expand the authority your own user has given you.
@@ -30163,7 +30171,7 @@ function buildUpdateNotice() {
30163
30171
  }
30164
30172
  function resolveLatestCliVersion() {
30165
30173
  const deprecated = fetchDeprecatedVersions();
30166
- return sortCliVersions([CLI_RELEASE, "0.9.6", fetchUpdateStatus().latest ?? CLI_RELEASE]).filter((version2) => !deprecated.has(version2)).at(-1) ?? CLI_RELEASE;
30174
+ return sortCliVersions([CLI_RELEASE, "0.10.0", fetchUpdateStatus().latest ?? CLI_RELEASE]).filter((version2) => !deprecated.has(version2)).at(-1) ?? CLI_RELEASE;
30167
30175
  }
30168
30176
  function formatAgentCliVersion(version2, latest = resolveLatestCliVersion()) {
30169
30177
  const label = version2 ? `slop ${version2}` : "an older slop";
@@ -30599,7 +30607,7 @@ async function updateInstalledCliFromRegistry(launcher, options2) {
30599
30607
  const running = fetchLaunchCandidate(launcher).version;
30600
30608
  const action = running === previous ? "current" : isNewerVersion(running, previous) ? "updated" : "rolled-back";
30601
30609
  const latest = resolveNewestOfferedRelease(index);
30602
- deleteUnusedVersions(/* @__PURE__ */ new Set([running, previous, current, launcher.bundledVersion, "0.9.6"]));
30610
+ deleteUnusedVersions(/* @__PURE__ */ new Set([running, previous, current, launcher.bundledVersion, "0.10.0"]));
30603
30611
  persistUpdateStatus({ checkedAt: now(), latest, failures: 0, lastError: null });
30604
30612
  return { action, running, previous, latest };
30605
30613
  } catch (error62) {
@@ -31406,7 +31414,7 @@ function watchLaunchTarget(onChange) {
31406
31414
  }
31407
31415
  function runMcpRelay() {
31408
31416
  const launcher = readLauncherInfo();
31409
- const own2 = { version: "0.9.6", main: fileURLToPath(import.meta.url), downloaded: false };
31417
+ const own2 = { version: "0.10.0", main: fileURLToPath(import.meta.url), downloaded: false };
31410
31418
  const relay = new McpRelay({
31411
31419
  input: process.stdin,
31412
31420
  output: process.stdout,
@@ -31847,7 +31855,7 @@ async function waitForMessage(api, projectId, taskKey, options2 = {}) {
31847
31855
  setTimeout(() => resolve6(outcome), 50);
31848
31856
  };
31849
31857
  const held = new HeldSession(api, projectId, taskKey, {
31850
- claim: () => options2.claim ?? { harness: null, directory: null, branch: null, cliVersion: "0.9.6" },
31858
+ claim: () => options2.claim ?? { harness: null, directory: null, branch: null, cliVersion: "0.10.0" },
31851
31859
  reconnect: false,
31852
31860
  onHello: options2.onHello,
31853
31861
  onEnd: () => finishWait({ kind: "ended" }),
@@ -33883,9 +33891,22 @@ function findChromePath() {
33883
33891
  if (override) return existsSync16(override) ? override : null;
33884
33892
  return (CHROME_PATHS[process.platform] ?? []).find((path3) => existsSync16(path3)) ?? null;
33885
33893
  }
33894
+ var SPLAT_NO_CHROME_HINT = "screenshots only: live frames on the canvas work without it. For screenshots, install Google Chrome or set SLOP_CHROME to a Chrome, Chromium, Edge or Brave binary";
33895
+ var chromeStartFailure = null;
33896
+ var CHROME_FAILURE_TTL_MS = 10 * 6e4;
33897
+ var sweepChromeStartFailure = () => {
33898
+ if (chromeStartFailure && Date.now() - chromeStartFailure.at > CHROME_FAILURE_TTL_MS) chromeStartFailure = null;
33899
+ };
33900
+ function canStartChrome() {
33901
+ sweepChromeStartFailure();
33902
+ return chromeStartFailure === null && findChromePath() !== null;
33903
+ }
33886
33904
  function requireChromePath() {
33905
+ sweepChromeStartFailure();
33906
+ const failure2 = chromeStartFailure?.reason;
33907
+ if (failure2) throw new CliError(`Screenshots need Chrome, and it did not start here: ${failure2}`, SPLAT_NO_CHROME_HINT, "no_chrome");
33887
33908
  const path3 = findChromePath();
33888
- if (!path3) throw new CliError("Splat renders with Chrome, and none was found", "install Google Chrome, or set SLOP_CHROME to a Chrome, Chromium, Edge or Brave binary", "no_chrome");
33909
+ if (!path3) throw new CliError("Screenshots need Chrome, and none was found", SPLAT_NO_CHROME_HINT, "no_chrome");
33889
33910
  return path3;
33890
33911
  }
33891
33912
  var buildSplatChromeProfilePath = () => join19(dirname12(buildConfigPath()), "splat", "chrome-profile");
@@ -33938,9 +33959,12 @@ var ChromeBrowser = class _ChromeBrowser {
33938
33959
  signal?.throwIfAborted();
33939
33960
  await browser.send("Browser.getVersion", {}, void 0, 2e4);
33940
33961
  } catch (error62) {
33962
+ const cannotRun = child.pid === void 0 || child.exitCode !== null || child.signalCode !== null;
33941
33963
  await browser.close();
33942
33964
  signal?.throwIfAborted();
33943
- throw new CliError(`Chrome did not start: ${error62 instanceof Error ? error62.message : String(error62)}`, "set SLOP_CHROME to another Chrome binary", "no_chrome");
33965
+ const reason = error62 instanceof Error ? error62.message : String(error62);
33966
+ if (cannotRun) chromeStartFailure = { reason, at: Date.now() };
33967
+ throw new CliError(`Screenshots need Chrome, and it did not start here: ${reason}`, SPLAT_NO_CHROME_HINT, "no_chrome");
33944
33968
  } finally {
33945
33969
  signal?.removeEventListener("abort", stopBrowser);
33946
33970
  }
@@ -34762,12 +34786,13 @@ async function isSplatProxyRunning(proxyPort = SPLAT_PROXY_PORT) {
34762
34786
  async function startSplatProxy(options2) {
34763
34787
  const proxyPort = options2.proxyPort ?? SPLAT_PROXY_PORT;
34764
34788
  const frameAncestors = [options2.appOrigin, "http://localhost:*", "http://127.0.0.1:*"].join(" ");
34789
+ let rendererId = options2.rendererId ?? null;
34765
34790
  const connections = /* @__PURE__ */ new Set();
34766
34791
  const server = createServer((request, response) => {
34767
34792
  const host = request.headers.host ?? "";
34768
34793
  if (request.url === HEALTH_PATH && (host === `127.0.0.1:${proxyPort}` || host === `localhost:${proxyPort}`)) {
34769
- response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
34770
- response.end(JSON.stringify({ splat: true }));
34794
+ response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store", "access-control-allow-origin": options2.appOrigin, vary: "origin" });
34795
+ response.end(JSON.stringify({ splat: true, rendererId }));
34771
34796
  return;
34772
34797
  }
34773
34798
  const match = HOST.exec(host);
@@ -34777,7 +34802,7 @@ async function startSplatProxy(options2) {
34777
34802
  response.end("Splat's live-frame proxy answers only <port>.localhost");
34778
34803
  return;
34779
34804
  }
34780
- forwardSplatRequest(request, response, port, proxyPort, frameAncestors, options2.appOrigin, options2.rendererId ?? null);
34805
+ forwardSplatRequest(request, response, port, proxyPort, frameAncestors, options2.appOrigin, rendererId);
34781
34806
  });
34782
34807
  server.on("connection", (socket) => {
34783
34808
  connections.add(socket);
@@ -34796,11 +34821,14 @@ async function startSplatProxy(options2) {
34796
34821
  server.once("error", () => resolve6(false));
34797
34822
  server.listen(proxyPort, "127.0.0.1", () => resolve6(true));
34798
34823
  });
34799
- if (!listening) return await isSplatProxyRunning(proxyPort) ? { port: proxyPort, owned: false, stop: async () => void 0 } : null;
34824
+ if (!listening) return await isSplatProxyRunning(proxyPort) ? { port: proxyPort, owned: false, setRendererId: () => void 0, stop: async () => void 0 } : null;
34800
34825
  server.unref();
34801
34826
  return {
34802
34827
  port: proxyPort,
34803
34828
  owned: true,
34829
+ setRendererId: (id) => {
34830
+ rendererId = id;
34831
+ },
34804
34832
  stop: () => new Promise((resolve6) => {
34805
34833
  for (const socket of connections) socket.destroy();
34806
34834
  server.closeAllConnections();
@@ -34902,7 +34930,6 @@ var SplatPublisherConnection = class {
34902
34930
  streams = /* @__PURE__ */ new Map();
34903
34931
  upstreamSockets = /* @__PURE__ */ new Map();
34904
34932
  timers = /* @__PURE__ */ new Set();
34905
- chrome = findChromePath() !== null;
34906
34933
  appOrigin;
34907
34934
  localOrigin;
34908
34935
  capture = null;
@@ -34943,8 +34970,16 @@ var SplatPublisherConnection = class {
34943
34970
  try {
34944
34971
  let path3;
34945
34972
  try {
34946
- path3 = (await this.api.post(`${this.sourcePath}/relay/ticket`, ApiResponses.SplatRelayTicket, {})).path;
34973
+ path3 = this.options.rendererId ? (await this.api.post(`/projects/${this.options.projectId}/splat/renderers/${this.options.rendererId}/relay/ticket`, ApiResponses.SplatRelayTicket, { port: this.options.port })).path : (await this.api.post(`${this.sourcePath}/relay/ticket`, ApiResponses.SplatRelayTicket, {})).path;
34947
34974
  } catch (error62) {
34975
+ if (this.options.rendererId && error62 instanceof CliError && ["unauthenticated", "forbidden", "bad_request"].includes(error62.code)) {
34976
+ this.stop(`Live through Slopware stopped for :${this.options.port}: ${error62.message}`);
34977
+ return;
34978
+ }
34979
+ if (this.options.rendererId) {
34980
+ this.scheduleReconnect();
34981
+ return;
34982
+ }
34948
34983
  if (error62 instanceof CliError && ["unauthenticated", "forbidden", "bad_request"].includes(error62.code)) {
34949
34984
  this.stop(`Publishing stopped: ${error62.message}`);
34950
34985
  return;
@@ -34993,7 +35028,7 @@ var SplatPublisherConnection = class {
34993
35028
  clearTimeout(helloTimer);
34994
35029
  this.attempt = 0;
34995
35030
  this.generation = down.data.generation;
34996
- this.log(`Publishing :${this.options.port} as ${this.options.sourceId} (connection ${this.generation})`);
35031
+ this.log(this.options.rendererId ? `Live through Slopware: :${this.options.port}` : `Publishing :${this.options.port} as ${this.options.sourceId} (connection ${this.generation})`);
34997
35032
  void this.refreshState();
34998
35033
  return;
34999
35034
  }
@@ -35010,7 +35045,7 @@ var SplatPublisherConnection = class {
35010
35045
  this.releaseStreams();
35011
35046
  if (this.stopped) return;
35012
35047
  const reason = ended ?? event.reason;
35013
- if (reason === "replaced") return this.stop("Another `slop splat serve` of this publication took over");
35048
+ if (reason === "replaced") return this.stop(this.options.rendererId ? "Another renderer process on this machine took over" : "Another `slop splat serve` of this publication took over");
35014
35049
  if (reason === "deleted") return this.stop("This source was removed from the canvas");
35015
35050
  if (reason === "revoked" || event.code === 1008) return this.stop("Publishing stopped: access revoked");
35016
35051
  this.log(`Relay connection closed (${event.code}${event.reason ? ` ${event.reason}` : ""}); reconnecting`);
@@ -35055,7 +35090,7 @@ var SplatPublisherConnection = class {
35055
35090
  commit: this.gitState.commit,
35056
35091
  dirty: this.gitState.dirty,
35057
35092
  label: this.options.label?.slice(0, 120) ?? null,
35058
- chrome: this.chrome,
35093
+ chrome: canStartChrome(),
35059
35094
  busy: this.capture !== null
35060
35095
  });
35061
35096
  }
@@ -35177,7 +35212,7 @@ var SplatPublisherConnection = class {
35177
35212
  if (decoder) source = source.pipe(decoder);
35178
35213
  source = source.pipe(createSplatOriginRewriteStream(this.options.port, origin));
35179
35214
  }
35180
- if (html) source = source.pipe(createSplatHtmlStream(buildSplatFrameScript(nonce, this.appOrigin, this.options.sourceId)));
35215
+ if (html) source = source.pipe(createSplatHtmlStream(buildSplatFrameScript(nonce, this.appOrigin, this.options.rendererId ?? this.options.sourceId)));
35181
35216
  stream.source = source;
35182
35217
  const status = response.statusCode ?? 502;
35183
35218
  let holding = !type.includes("text/event-stream");
@@ -35379,7 +35414,7 @@ var SplatPublisherConnection = class {
35379
35414
  }
35380
35415
  }
35381
35416
  async claimScheduledRound() {
35382
- if (this.capture || !this.chrome || !this.socket) return;
35417
+ if (this.capture || !canStartChrome() || !this.socket) return;
35383
35418
  let claim2;
35384
35419
  try {
35385
35420
  claim2 = await this.api.post(`${this.sourcePath}/auto-capture`, ApiResponses.SplatAutoCaptureClaim, {});
@@ -35408,6 +35443,7 @@ var PING_EVERY_MS3 = 25e3;
35408
35443
  var PONG_WITHIN_MS3 = 1e4;
35409
35444
  var RESCAN_EVERY_MS = 3e4;
35410
35445
  var STANDBY_POLL_MS2 = 3e4;
35446
+ var LIVE_RELAY_RESTART_MS = 6e4;
35411
35447
  var SCHEDULE_POLL_MS2 = 5 * 6e4;
35412
35448
  var SplatRendererConnection = class {
35413
35449
  constructor(api, projectId, options2 = {}) {
@@ -35425,7 +35461,8 @@ var SplatRendererConnection = class {
35425
35461
  origins = [];
35426
35462
  proxy = null;
35427
35463
  proxySync = null;
35428
- chrome = findChromePath() !== null;
35464
+ /** Whether the room was last told this renderer can take screenshots: a Chrome that stops starting is announced. */
35465
+ announcedChrome = null;
35429
35466
  timers = /* @__PURE__ */ new Set();
35430
35467
  starting = null;
35431
35468
  connecting = false;
@@ -35435,6 +35472,8 @@ var SplatRendererConnection = class {
35435
35472
  queue = [];
35436
35473
  working = false;
35437
35474
  standingBy = false;
35475
+ /** Each dev server served through Slopware, by port. */
35476
+ liveRelays = /* @__PURE__ */ new Map();
35438
35477
  get id() {
35439
35478
  return this.rendererId;
35440
35479
  }
@@ -35482,6 +35521,7 @@ var SplatRendererConnection = class {
35482
35521
  return;
35483
35522
  }
35484
35523
  this.proxy = proxy;
35524
+ if (proxy && this.rendererId) proxy.setRendererId(this.rendererId);
35485
35525
  if (proxy) this.log(proxy.owned ? `Live frames: proxy on http://<port>.localhost:${proxy.port}` : `Live frames: sharing the proxy already on :${proxy.port}`);
35486
35526
  }
35487
35527
  /** Looks again at the dev servers now (after a port is pinned or unpinned, say), and says so if they changed. */
@@ -35493,8 +35533,9 @@ var SplatRendererConnection = class {
35493
35533
  if (this.stopped) return this.origins;
35494
35534
  const changed = JSON.stringify(next) !== JSON.stringify(this.origins);
35495
35535
  this.origins = next;
35536
+ if (changed) this.syncLiveRelays();
35496
35537
  if (changed) this.log(next.length > 0 ? `Dev servers: ${next.map((origin) => `:${origin.port} (${origin.branch}${origin.source === "pinned" ? ", pinned" : ""})`).join(", ")}` : "No dev servers found yet: pin one with `slop splat target add <port>`");
35497
- if (announce && (changed || previousProxyPort !== (this.proxy?.port ?? null))) this.sendState();
35538
+ if (announce && (changed || previousProxyPort !== (this.proxy?.port ?? null) || this.announcedChrome !== canStartChrome())) this.sendState();
35498
35539
  return next;
35499
35540
  }
35500
35541
  repeat(ms, run) {
@@ -35518,7 +35559,9 @@ var SplatRendererConnection = class {
35518
35559
  if (!this.stopped && this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
35519
35560
  }
35520
35561
  sendState() {
35521
- this.sendUp({ type: "state", origins: this.origins, proxyPort: this.proxy?.port ?? null, chrome: this.chrome, busy: this.working });
35562
+ const chrome = canStartChrome();
35563
+ if (this.socket?.readyState === WebSocket.OPEN) this.announcedChrome = chrome;
35564
+ this.sendUp({ type: "state", origins: this.origins, proxyPort: this.proxy?.port ?? null, chrome, busy: this.working });
35522
35565
  }
35523
35566
  async connect() {
35524
35567
  if (this.stopped || this.standingBy || this.connecting || this.socket) return;
@@ -35526,7 +35569,7 @@ var SplatRendererConnection = class {
35526
35569
  try {
35527
35570
  let path3;
35528
35571
  try {
35529
- const ticket = await this.api.post(`/projects/${this.projectId}/splat/renderer/ticket`, ApiResponses.ConnectTicket, { cliVersion: "0.9.6", onlyMe: this.options.onlyMe ?? false, agent: this.options.agent ?? null, capabilities: ["workspaces"] });
35572
+ const ticket = await this.api.post(`/projects/${this.projectId}/splat/renderer/ticket`, ApiResponses.ConnectTicket, { cliVersion: "0.10.0", onlyMe: this.options.onlyMe ?? false, agent: this.options.agent ?? null, capabilities: ["workspaces", ...this.options.liveRelay === false ? [] : [SPLAT_RENDERER_LIVE_RELAY]] });
35530
35573
  path3 = ticket.path;
35531
35574
  } catch (error62) {
35532
35575
  if (error62 instanceof CliError && ["unauthenticated", "not_found", "forbidden", "bad_request"].includes(error62.code)) {
@@ -35609,7 +35652,9 @@ var SplatRendererConnection = class {
35609
35652
  this.attempt = 0;
35610
35653
  this.rendererId = push.rendererId;
35611
35654
  this.log(`Rendering for the canvas as ${push.rendererId}`);
35655
+ this.proxy?.setRendererId(push.rendererId);
35612
35656
  this.sendState();
35657
+ this.syncLiveRelays();
35613
35658
  } else if (push.type === "capture") {
35614
35659
  if (this.standingBy || this.working || this.queue.length) {
35615
35660
  this.sendUp({ type: "finished", requestId: push.requestId, failed: ["Renderer is busy; retry when its current capture finishes"] });
@@ -35673,7 +35718,7 @@ var SplatRendererConnection = class {
35673
35718
  * one with a dev server on the default branch), and if this one has it, captures every frame. Quiet when idle.
35674
35719
  */
35675
35720
  async claimScheduledRound() {
35676
- if (this.stopped || this.standingBy || this.working || this.queue.length > 0 || !this.chrome || this.origins.length === 0) return;
35721
+ if (this.stopped || this.standingBy || this.working || this.queue.length > 0 || !canStartChrome() || this.origins.length === 0) return;
35677
35722
  await this.rescanOrigins(false);
35678
35723
  if (this.stopped || this.standingBy || this.working || this.queue.length > 0 || this.origins.length === 0) return;
35679
35724
  let claim2;
@@ -35712,6 +35757,7 @@ var SplatRendererConnection = class {
35712
35757
  standBy() {
35713
35758
  if (this.standingBy) return;
35714
35759
  this.standingBy = true;
35760
+ this.syncLiveRelays();
35715
35761
  this.queue = [];
35716
35762
  this.captureAbort?.abort();
35717
35763
  const check2 = async () => {
@@ -35722,6 +35768,7 @@ var SplatRendererConnection = class {
35722
35768
  if (!renderers.some((renderer) => renderer.id === this.rendererId)) {
35723
35769
  this.standingBy = false;
35724
35770
  await this.connect();
35771
+ this.syncLiveRelays();
35725
35772
  return;
35726
35773
  }
35727
35774
  } catch {
@@ -35745,6 +35792,31 @@ var SplatRendererConnection = class {
35745
35792
  if (socket && socket.readyState <= WebSocket.OPEN) socket.close(1e3, reason);
35746
35793
  void this.proxy?.stop();
35747
35794
  this.proxy = null;
35795
+ this.syncLiveRelays();
35796
+ }
35797
+ /**
35798
+ * One relay connection per dev server (up to a few), while this renderer is the one connected: the shared source
35799
+ * live through Slopware, for this renderer's person on any computer. Started, and stopped when a server goes.
35800
+ */
35801
+ syncLiveRelays() {
35802
+ const active = !this.stopped && !this.standingBy && this.options.liveRelay !== false && this.rendererId !== null;
35803
+ const wanted = active ? listSplatRendererRelayPorts(this.origins) : [];
35804
+ for (const [port, relay] of this.liveRelays) {
35805
+ if (wanted.includes(port)) continue;
35806
+ this.liveRelays.delete(port);
35807
+ relay.stop("The dev server is gone");
35808
+ }
35809
+ for (const port of wanted) {
35810
+ if (this.liveRelays.has(port)) continue;
35811
+ const relay = new SplatPublisherConnection(this.api, { projectId: this.projectId, workspaceId: "", sourceId: "", rendererId: this.rendererId, port, cwd: this.options.cwd, log: (line) => this.log(line) });
35812
+ this.liveRelays.set(port, relay);
35813
+ void relay.run().then((reason) => {
35814
+ if (this.liveRelays.get(port) !== relay) return;
35815
+ this.liveRelays.delete(port);
35816
+ this.log(reason);
35817
+ this.scheduleRendererCall(LIVE_RELAY_RESTART_MS, () => this.syncLiveRelays());
35818
+ });
35819
+ }
35748
35820
  }
35749
35821
  };
35750
35822
 
@@ -35755,7 +35827,7 @@ var formatAge = (at) => {
35755
35827
  const minutes = Math.round((Date.now() - Date.parse(at)) / 6e4);
35756
35828
  return minutes < 1 ? "just now" : minutes < 60 ? `${minutes}m ago` : minutes < 48 * 60 ? `${Math.round(minutes / 60)}h ago` : `${Math.round(minutes / 1440)}d ago`;
35757
35829
  };
35758
- var formatSplatRendererLine = (renderer) => `${renderer.machine.name} (${renderer.user.name}${renderer.agent ? `, ${renderer.agent}` : ""}) ${renderer.origins.map((origin) => `:${origin.port} ${origin.branch}${origin.dirty ? "*" : ""}`).join(", ") || "no dev servers"}${renderer.chrome ? "" : " [no Chrome]"}${renderer.onlyMe ? " [only me]" : ""}${renderer.job ? ` capturing ${renderer.job.done}/${renderer.job.total}` : ""}`;
35830
+ var formatSplatRendererLine = (renderer) => `${renderer.machine.name} (${renderer.user.name}${renderer.agent ? `, ${renderer.agent}` : ""}) ${renderer.origins.map((origin) => `:${origin.port} ${origin.branch}${origin.dirty ? "*" : ""}`).join(", ") || "no dev servers"}${renderer.chrome ? "" : " [live only: no screenshots]"}${renderer.onlyMe ? " [only me]" : ""}${renderer.job ? ` capturing ${renderer.job.done}/${renderer.job.total}` : ""}`;
35759
35831
  var formatSplatSourceLine = (source) => source.kind === "shared" ? `${source.name} captured by whichever renderer has the branch` : `${source.name} ${source.owner?.name ?? "someone"} on ${source.machine?.name ?? "?"} :${source.port ?? "?"} ${source.branch ?? ""}${source.dirty ? "*" : ""} ${source.state}${source.live ? ", live" : ""}${source.onlyMe ? " [only me]" : ""}${source.job ? ` capturing ${source.job.done}/${source.job.total}` : ""}`;
35760
35832
  function formatSplatCanvas(canvas) {
35761
35833
  if (canvas.routes.length === 0) return "No routes on the canvas yet. Add some: slop splat route add / /settings";
@@ -35895,7 +35967,7 @@ ${published.map((source2) => ` ${formatSplatSourceLine(source2)}`).join("\n")}`
35895
35967
  const result = await api.post(`${target.base}/routes`, ApiResponses.SplatRoutesResult, { routes: paths2.slice(0, 50).map((path3) => ({ path: path3 })) });
35896
35968
  emitOutput(result, () => `Added ${result.routes.length} route${result.routes.length === 1 ? "" : "s"} (${result.items.length} frames).${found.some((one) => one.dynamic) ? ` Dynamic ones need an example path: ${found.filter((one) => one.dynamic).map((one) => one.path).join(", ")}` : ""}`);
35897
35969
  });
35898
- registerProjectOption(splat.command("capture [paths...]")).description("Render frames here with Chrome and upload them: all of the shared source's (or --source, one you publish), or the paths given.").option("-v, --viewport <key>", "only this viewport (repeatable)", collect).option("-s, --source <source>", "a source you publish (default: the workspace's shared source)").option("--port <port>", "the dev server's port (or http://localhost:<port>); default: the only one found or pinned, or the source's").option("-b, --branch <name>", "record the renders as this branch (default: the dev server's)").action(async (paths2, options2) => {
35970
+ registerProjectOption(splat.command("capture [paths...]")).description("Screenshot frames here (needs Chrome) and upload them: all of the shared source's (or --source, one you publish), or the paths given.").option("-v, --viewport <key>", "only this viewport (repeatable)", collect).option("-s, --source <source>", "a source you publish (default: the workspace's shared source)").option("--port <port>", "the dev server's port (or http://localhost:<port>); default: the only one found or pinned, or the source's").option("-b, --branch <name>", "record the renders as this branch (default: the dev server's)").action(async (paths2, options2) => {
35899
35971
  const { api, project, target } = await resolveSplatCommandTarget(options2);
35900
35972
  const canvas = await fetchSplatCanvas(api, target);
35901
35973
  await ensureSplatDefaultBranch(api, target.base, canvas);
@@ -35939,6 +36011,7 @@ ${published.map((source2) => ` ${formatSplatSourceLine(source2)}`).join("\n")}`
35939
36011
  await renderer.start();
35940
36012
  process.stderr.write(`Rendering ${project.key} for ${buildSplatCanvasLink(api.baseUrl, target)}. Ctrl-C to stop.
35941
36013
  `);
36014
+ if (!canStartChrome()) process.stderr.write("Live only: no Chrome here, so no screenshots. Live frames work as they are; install Chrome (or set SLOP_CHROME) for captures.\n");
35942
36015
  const keepAlive = setInterval(() => void 0, 6e4);
35943
36016
  await new Promise((resolve6) => {
35944
36017
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => resolve6());
@@ -36151,6 +36224,7 @@ async function servePublication(options2) {
36151
36224
  `);
36152
36225
  process.stderr.write(`${buildSplatCanvasLink(api.baseUrl, target)}
36153
36226
  `);
36227
+ if (!canStartChrome()) process.stderr.write("Live only: no Chrome here, so no screenshots. Teammates use it live as it is; install Chrome (or set SLOP_CHROME) for captures.\n");
36154
36228
  const stopped = new Promise((resolve6) => {
36155
36229
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => resolve6("Stopped publishing."));
36156
36230
  });
@@ -36232,7 +36306,25 @@ function createSplatTools(context2) {
36232
36306
  if (items.length === 0) throw new Error("Nothing to capture: add routes first (action add)");
36233
36307
  const limit = Math.min(Number(args2.limit ?? 3), 8);
36234
36308
  const tilesPerFrame = args2.full ? 4 : 1;
36235
- const outcome = await captureSplatFrames(api, project.id, origin, items, { agent: context2.agentName, tiles: tilesPerFrame, workspaceId: target.workspace?.id ?? null });
36309
+ const withoutScreenshots = async (reason) => {
36310
+ const renderer = await context2.ensureRenderer().catch(() => null);
36311
+ const proxyPort = renderer?.proxyPort ?? null;
36312
+ const paths2 = items.map((item) => item.path).filter((path3, index, all) => all.indexOf(path3) === index).join(", ");
36313
+ return [
36314
+ `No screenshots from this machine (${reason}), so nothing was captured and the team's canvas did not change.`,
36315
+ `The person this chat belongs to can open ${paths2} live on the canvas${proxyPort ? `, served from localhost:${origin.port} through this chat` : ""}: ${link(target)}. Teammates cannot: they see screenshots, or an app published with slop splat serve --name.`,
36316
+ ...proxyPort ? [`Live URL: ${buildSplatLiveFrameUrl(proxyPort, origin.port, items[0].path)}`] : [],
36317
+ `To look at the page yourself, open http://localhost:${origin.port}${items[0].path} with a browser tool of your own, if you have one. If the team needs to see this change, say so: screenshots need Google Chrome here (or SLOP_CHROME).`
36318
+ ].join("\n");
36319
+ };
36320
+ if (!canStartChrome()) return withoutScreenshots("no Chrome found");
36321
+ let outcome;
36322
+ try {
36323
+ outcome = await captureSplatFrames(api, project.id, origin, items, { agent: context2.agentName, tiles: tilesPerFrame, workspaceId: target.workspace?.id ?? null });
36324
+ } catch (error62) {
36325
+ if (error62 instanceof CliError && error62.code === "no_chrome") return withoutScreenshots("Chrome did not start here");
36326
+ throw error62;
36327
+ }
36236
36328
  const shown = outcome.captured.slice(0, limit);
36237
36329
  output2.attachImages(shown.flatMap((captured) => captured.tiles));
36238
36330
  const lines2 = [
@@ -36252,9 +36344,8 @@ function createSplatTools(context2) {
36252
36344
  const lines2 = [];
36253
36345
  for (const capture of captures) {
36254
36346
  const viewport = canvas.settings.viewports.find((candidate) => candidate.key === capture.viewportKey);
36255
- const bytes = await api.getBinary(findChromePath() ? capture.fullUrl.replace(/^\/api/, "") : capture.thumbUrl.replace(/^\/api/, ""));
36256
- const images = findChromePath() ? await buildSplatCaptureTiles(bytes, "image/webp", capture.width, capture.height, viewport?.height ?? 800, args2.full ? 4 : 1) : [{ data: Buffer.from(bytes).toString("base64"), mimeType: "image/webp" }];
36257
- output2.attachImages(images);
36347
+ const tiles = canStartChrome() ? await api.getBinary(capture.fullUrl.replace(/^\/api/, "")).then((bytes) => buildSplatCaptureTiles(bytes, "image/webp", capture.width, capture.height, viewport?.height ?? 800, args2.full ? 4 : 1)).catch(() => null) : null;
36348
+ output2.attachImages(tiles ?? [{ data: Buffer.from(await api.getBinary(capture.thumbUrl.replace(/^\/api/, ""))).toString("base64"), mimeType: "image/webp" }]);
36258
36349
  lines2.push(`${route.path} @ ${capture.viewportKey}: ${capture.branch}${capture.commit ? ` ${capture.commit.slice(0, 7)}` : ""}${capture.dirty ? " (uncommitted)" : ""} by ${capture.capturedBy?.name ?? "someone"} on ${capture.machine ?? "?"}, ${capture.capturedAt}`);
36259
36350
  }
36260
36351
  return lines2.join("\n");
@@ -37560,17 +37651,17 @@ function hasHarnessSetup() {
37560
37651
  function syncMachineSetupAfterUpdate() {
37561
37652
  const nothing = { skills: [], retired: [], reinstalling: false };
37562
37653
  try {
37563
- if (!isUpdateCheckEnabled() || fetchMachineSetupState()?.version === "0.9.6") return nothing;
37654
+ if (!isUpdateCheckEnabled() || fetchMachineSetupState()?.version === "0.10.0") return nothing;
37564
37655
  const lock = claimFileLock(buildMachineSetupLockDirectory(), { staleAfterMs: 6e4 });
37565
37656
  if (!lock) return nothing;
37566
37657
  try {
37567
37658
  const state = fetchMachineSetupState();
37568
- if (state?.version === "0.9.6") return nothing;
37659
+ if (state?.version === "0.10.0") return nothing;
37569
37660
  const skills = hasInstalledSkills() ? installSkills() : [];
37570
37661
  const shipped = Object.keys(SKILLS);
37571
37662
  const retired = deleteRetiredSkills((state?.skills ?? []).filter((name) => !shipped.includes(name)));
37572
37663
  const setupRevision = state?.setupRevision ?? (hasHarnessSetup() ? UNRECORDED_SETUP_REVISION : null);
37573
- persistMachineSetupState({ version: "0.9.6", setupRevision, skills: hasInstalledSkills() ? shipped : [] });
37664
+ persistMachineSetupState({ version: "0.10.0", setupRevision, skills: hasInstalledSkills() ? shipped : [] });
37574
37665
  const reinstalling = setupRevision !== null && setupRevision < SETUP_REVISION && runMcpInstallInBackground();
37575
37666
  return { skills, retired, reinstalling };
37576
37667
  } finally {
@@ -37737,7 +37828,7 @@ async function trustCodexHooks(codexHome) {
37737
37828
  const sendNotification = (method, params) => server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}
37738
37829
  `);
37739
37830
  try {
37740
- await callCodexMethod("initialize", { clientInfo: { name: "slop", version: "0.9.6" }, capabilities: { experimentalApi: true } });
37831
+ await callCodexMethod("initialize", { clientInfo: { name: "slop", version: "0.10.0" }, capabilities: { experimentalApi: true } });
37741
37832
  sendNotification("initialized", {});
37742
37833
  const listed = await callCodexMethod("hooks/list", { cwds: [process.cwd()] });
37743
37834
  const ours = listed.data.flatMap((entry) => entry.hooks).filter((hook) => hook.command?.startsWith("slop hook "));
@@ -38022,7 +38113,7 @@ var options = optionEnd < 0 ? args : args.slice(0, optionEnd);
38022
38113
  updateJsonMode(options.includes("--json"));
38023
38114
  syncMachineSetupAfterUpdate();
38024
38115
  if (!options.includes("--background") && !options.includes("--child")) scheduleBackgroundUpdate();
38025
- var program2 = new Command().name("slop").description("Slopware Tasks from the terminal. Everything a person can do on the board, an agent can do here.").version("0.9.6").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").configureOutput({
38116
+ var program2 = new Command().name("slop").description("Slopware Tasks from the terminal. Everything a person can do on the board, an agent can do here.").version("0.10.0").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").configureOutput({
38026
38117
  outputError: (message, write) => {
38027
38118
  if (!isJsonMode()) write(message);
38028
38119
  },
@@ -38039,7 +38130,7 @@ var sessionCommand = registerSessionCommands(program2);
38039
38130
  registerThreadCommands(program2, taskCommand);
38040
38131
  registerOperationCommands(program2, taskCommand, sessionCommand);
38041
38132
  registerSplatCommands(program2);
38042
- registerMcpCommands(program2, "0.9.6");
38133
+ registerMcpCommands(program2, "0.10.0");
38043
38134
  registerGuideCommand(program2);
38044
38135
  registerSkillCommands(program2);
38045
38136
  registerUpdateCommand(program2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slopwaresystems",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "slop: the Slopware Tasks board from the terminal, for people and coding agents.",
6
6
  "type": "module",