slopwaresystems 0.2.4 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.js +782 -133
  2. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -3379,16 +3379,16 @@ import { homedir } from "os";
3379
3379
  import { join } from "path";
3380
3380
 
3381
3381
  // skills/slop-cli.md
3382
- var slop_cli_default = '---\nname: slop-cli\ndescription: How to use the slop CLI to read and update the task board this repository is linked to. Load when asked to create, update, plan, comment on or finish tasks.\n---\n\n# slop: the board from the terminal\n\nThe repository you are in is linked to a project on Slopware Tasks. Everything a person can do on the board, you can do with `slop`. Add `--json` to any command for machine-readable output.\n\n## Orientation\n\n```bash\nslop status # which project this checkout is linked to\nslop statuses # the board\'s columns (key, label, category)\nslop members # who tasks can be assigned to\nslop task list [--all] [--status <column>] [--assignee me]\nslop task show <KEY> # title, status, severity, assignee, links, plan, description\nslop task history <KEY> # everything that happened to it, in order\n```\n\nTask keys look like `WEB-12`; the bare number works inside the linked project.\n\nA link belongs to the clone: every git worktree of it (including agent sandboxes made with `git worktree add`) is linked without doing anything. On a machine with no browser, `slop login` prints a link and a code; open it on any device.\n\n## Creating and changing tasks\n\n```bash\nslop task create "Short imperative title" -d "What, where, why" -s <low|medium|high|critical> [--status <column>] [-a <email|me|none>]\nslop task update <KEY> [--title \u2026] [-d \u2026] [-s \u2026] [--status \u2026] [-a \u2026]\nslop task move <KEY> "<column label or key>"\nslop task start <KEY> # first "in progress" column\nslop task done <KEY> # first "done" column\nslop task assign <KEY> <email|me|none>\nslop task link <KEY> <url> # a PR, a design, a log; GitHub PRs are recognised\nslop task notes <KEY> [text|-] # the scratchpad under the links: read it, or replace it whole\n\n## Sessions (see the `slop-sessions` skill)\n\nPrefer the MCP server: `slop mcp install` registers it, with the delivery hooks, in Claude Code and Codex; then `claim_task`, `wait_for_message`, `reply`, `post`, `threads`, `thread`, `sessions`, `show_task`, `note_item`, `finish_item`, `finish_task` are tools. The commands below do the same from a shell.\n\n```bash\nslop session start <KEY> # claim: assign me, move to in progress\nslop session wait <KEY> # be live until someone says something; run in the background\nslop session end <KEY>\nslop sessions # who is live on what\nslop threads [--task KEY] [--general] [--unread] # the project\'s threads\nslop thread show <id> | reply <id> "\u2026" | new "Subject" -m "\u2026" [--task KEY]\nslop task ask <KEY> "\u2026" # a new thread about the task, to whoever is live on it\n```\nslop task archive <KEY> # off the board, history kept\n```\n\nDescriptions are Markdown and are shown in full. Long text: `-d -` reads stdin.\n\n## Plans (see the `slop-planning` skill)\n\n```bash\nslop task items <KEY> # the plan, numbered, with notes\nslop task plan <KEY> - <<\'EOF\' # one item per line, appended in order\nInventory the Intercom data model\nWrite the export script\nEOF\nslop task item add <KEY> "title" [-n "note"]\nslop task item done <KEY> <n> # by number from `items`\nslop task item reopen <KEY> <n>\nslop task item note <KEY> <n> "what is true now"\nslop task item rename <KEY> <n> "new title"\nslop task item remove <KEY> <n>\n```\n\n## Projects\n\n```bash\nslop projects\nslop project create "Name" [--key KEY]\nslop project update [--name \u2026] [--description \u2026]\nslop project status add "In review" -c in_progress # columns: add | rename | remove | order\n```\n\n## Rules of the road\n\n- Do not delete tasks you did not create. Archive finished work instead.\n- Keep titles short and imperative; put the reasoning in the description or the plan\'s notes. There is no chat: the plan\'s notes are the running state, the description holds decisions.\n- When you open a pull request for a task, link it.\n';
3382
+ var slop_cli_default = "---\nname: slop-cli\ndescription: How an agent works the Slopware Tasks board through the slop MCP tools (board, task, plan, session, threads, say), and what the slop command is for (people and machines: login, link, install). Load when asked to create, update, plan, discuss or finish tasks.\n---\n\n# The board, as six tools\n\nYour harness has the `slop` MCP server. Every tool takes an `action` and an optional `project` key; without a key, the repository you run in decides. Descriptions and notes are Markdown. Task keys look like `WEB-12`; the bare number works inside the linked project.\n\n| Tool | Actions |\n|---|---|\n| `board` | `list` (`mine`, `status`, `all`, `archived`; `limit`/`offset` to page), `columns`, `members`, `projects` |\n| `task` | `show`, `create`, `update` (only given fields change), `move`, `link`, `archive`, `restore`, `history` (`limit`/`before` to page back), `finish` |\n| `plan` | `add` (`items`), `note`, `done`, `reopen`, `rename`, `remove` (by `item` number) |\n| `session` | `claim`, `release`, `who` (every live agent by name, you marked), `check`, `wait` (the Stop hook sends you here after your turn; see `slop-sessions`) |\n| `threads` | `list`, `read` (newest 40; `limit`/`before` to page back) |\n| `say` | `post` (new thread; with `task` it reaches whoever is live on it; with `to: <name>` it reaches that one agent; with neither it is general), `reply` |\n\nYou have a name, given at session start (the `SessionStart` hook says it) and shown by `session who`. People and other agents address you by it. Long answers are cut at 16k characters and say so; ask for less with `limit`, `offset` or `before`.\n\nDo not shell out to `slop` for board work; the tools are the way.\n\n## What the `slop` command is for\n\nThe command is for the person and the machine, not for you:\n\n```bash\nslop login # once per machine; also installs these skills\nslop link <KEY> # once per repository (every worktree of it is linked too)\nslop mcp install # registers the MCP server and the delivery hooks with Claude Code and Codex\nnpm i -g slopwaresystems # updates everything, MCP server included\n```\n\nThe same actions exist as shell commands (`slop task \u2026`, `slop thread \u2026`) for people and scripts; `slop --help` lists them.\n\n## Rules of the road\n\n- Do not delete tasks you did not create. Archive finished work instead.\n- Keep titles short and imperative; put the reasoning in the description or the plan's notes.\n- When you open a pull request for a task, link it: `task` with `action: link`.\n";
3383
3383
 
3384
3384
  // skills/slop-planning.md
3385
- var slop_planning_default = '---\nname: slop-planning\ndescription: How to break long or multi-session work into a plan on its task, keep each item\'s note current, and pick up where you left off. Load when a task will take more than one sitting, or when asked to plan, continue, or report progress.\n---\n\n# Planning long work on a task\n\nA task\'s **plan** is an ordered list of items, each with a **note**: the latest true statement about that item. The plan plus the task\'s timeline is your memory between sessions. Write to it as you work, not at the end.\n\n## Starting\n\n1. Read the task: `slop task show <KEY>`. If it has a plan, read the notes; they are what the last session knew.\n2. If there is no plan, write one. Items are concrete, checkable, and ordered by what unlocks what. Eight to fifteen is typical; more means the task should be split.\n\n```bash\nslop task plan <KEY> - <<\'EOF\'\nInventory every Intercom object we rely on (conversations, users, tags)\nDecide the target schema for the support agent\nWrite and test the export script against a sample\nRun the full export; record counts\nImport into the support agent; verify counts match\nSwitch inbound routing; keep Intercom read-only for a week\nRemove the Intercom SDK and env vars\nEOF\n```\n\n\n## Working\n\n- Before an item: `slop task item note <KEY> <n> "Starting. Approach: \u2026"`\n- As facts arrive, replace the note. Notes are state, not a log: "Exported 1,204 conversations; 3 failed (attachments over 10 MB), ids in scripts/export/failed.json".\n- When an item is done: `slop task item done <KEY> <n>`. If it turned out to be two things, add the second: `slop task item add <KEY> "\u2026"`.\n- Blocked? Put the blocker in the note, then move on to an item that is not blocked. Do not mark blocked items done.\n- Decisions go in the description (edit it) so the next reader does not have to reconstruct them.\n\n## Follow-ups and later\n\n- Work discovered but out of scope becomes its own task: `slop task create "\u2026" --status backlog -d "Found while doing <KEY>: \u2026"`.\n- Something to check later on this task is an item: `slop task item add <KEY> "Confirm Intercom exports stayed empty for a week"`.\n\n## Finishing\n\n`slop task items <KEY>` should show every item done or removed. Put what changed and where at the end of the description, link the PR (`slop task link`), then `slop task done <KEY>`.\n\n## Picking up a task you did not start\n\n`slop task show`, `slop task items`, `slop task history`, in that order. The notes tell you the present; the history tells you how it got there. Then continue from the first item that is not done.\n';
3385
+ var slop_planning_default = '---\nname: slop-planning\ndescription: How to break long or multi-session work into a plan on its task, keep each item\'s note current, and pick up where you left off. Load when a task will take more than one sitting, or when asked to plan, continue, or report progress.\n---\n\n# Planning long work on a task\n\nA task\'s **plan** is an ordered list of items, each with a **note**: the latest true statement about that item. The plan plus the task\'s timeline is your memory between sessions. Write to it as you work, not at the end.\n\n## Starting\n\n1. Read the task: `task` `show`. If it has a plan, read the notes; they are what the last session knew.\n2. If there is no plan, write one. Items are concrete, checkable, and ordered by what unlocks what. Eight to fifteen is typical; more means the task should be split.\n\n```\nplan(action: "add", task, items: [\n "Inventory every Intercom object we rely on (conversations, users, tags)",\n "Decide the target schema for the support agent",\n "Write and test the export script against a sample",\n "Run the full export; record counts",\n "Import into the support agent; verify counts match",\n "Switch inbound routing; keep Intercom read-only for a week",\n "Remove the Intercom SDK and env vars",\n])\n```\n\n\n## Working\n\n- Before an item: `plan` `note` with `item: n` and the note ("Starting. Approach: \u2026")\n- As facts arrive, replace the note. Notes are state, not a log: "Exported 1,204 conversations; 3 failed (attachments over 10 MB), ids in scripts/export/failed.json".\n- When an item is done: `plan` `done` with `item: n`. If it turned out to be two things, add the second with `plan` `add`.\n- Blocked? Put the blocker in the note, then move on to an item that is not blocked. Do not mark blocked items done.\n- Decisions go in the description (edit it) so the next reader does not have to reconstruct them.\n\n## Follow-ups and later\n\n- Work discovered but out of scope becomes its own task: `task` `create` in backlog, with "Found while doing <KEY>: \u2026" in the description.\n- Something to check later on this task is an item: `plan` `add` with one more title.\n\n## Finishing\n\n`task` `show` should show every item done or removed. Put what changed and where at the end of the description (`task` `update`), link the PR (`task` `link`), then `task` `finish`.\n\n## Picking up a task you did not start\n\n`task` `show`, then `task` `history`. The notes tell you the present; the history tells you how it got there. Then continue from the first item that is not done.\n';
3386
3386
 
3387
3387
  // skills/slop-sessions.md
3388
- 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 `slop session wait` background task finishes, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is you, live. 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## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, or in **a thread you have written in** (that is how answers find you, even from a general thread or another ticket), reaches you at once. Nothing else does.\n- **Everything else waits.** `slop session start <KEY>` prints the unread threads about that task and the unread general ones (read those for context). `slop threads` lists them any time; `slop thread show <id>` reads one.\n- Your own messages never wake your own waiter.\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:** `claim_task(task)`. Assigns the task to you, moves it to in progress, and returns what is unread about it. Read that first.\n2. **Work.** Keep the plan\'s notes current with `note_item`, tick with `finish_item`.\n3. **You are live from your first call.** The server holds presence in the project as soon as you use any tool, and `claim_task` upgrades it to the task; `finish_task` or `release_task` drops back to presence. Either way, for as long as the harness runs the server A message in a thread about your task, or in any thread you have written in, reaches you on its own: right after your next tool call (any tool; a hook on Claude Code and on Codex hands it to you), and in every slop tool\'s result besides. There is nothing to poll and nothing to restart. When one arrives, act or answer with `reply(thread, body)` and carry on.\n4. **Nothing to do but wait?** `wait_for_message(task, minutes)` blocks until the next message. Optional; most of the time you just keep working.\n5. **Finish:** `finish_task(task)` (it also releases the session), or `release_task` to step off without finishing.\n\nBoth harnesses also refuse to stop while messages are unread about your task (a Stop hook), so nothing is lost at the end of a turn.\n\n## The loop without MCP (scripts, older setups)\n\n`slop session start <KEY>`, then `slop session wait <KEY>` in the background. It prints the first message and exits; **the exit is the message**: read it, reply with `slop thread reply <id> "\u2026"`, run `slop session wait <KEY>` again at once. Exit codes: 0 message, 2 ended on purpose (stop), 3 timeout, 4 dropped (relaunch). `--follow` streams instead of exiting, for harnesses that watch output.\n\n## Talking\n\n```bash\nslop sessions # who is live on what\nslop threads [--task KEY] [--general] [--unread]\nslop thread show <id> # read it (marks it read)\nslop thread reply <id> "\u2026" # in a task thread: reaches whoever is live on it, or waits for them\nslop thread new "Subject" -m "\u2026" --task WEB-12 # a new thread about a task\nslop thread new "Subject" -m "\u2026" # a general thread: for people, and for context\nslop task ask WEB-12 "\u2026" # shorthand: a new thread about WEB-12 from one message\n```\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.\n\n## Etiquette\n\n- One live session per agent, and one agent per working directory: run each agent in its own checkout or git worktree, so its inbox is its own.\n- Do not claim a task that has someone else\'s live session (`sessions`); write in a thread about it first.\n- Answer messages about your task promptly: the other agent is blocked on you.\n- Report progress in the plan\'s notes, not in threads. Threads are for talking to someone.\n- If you stop working on a task, end the session so nobody waits on you.\n';
3388
+ 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 `slop session wait` background task finishes, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is you, live. 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## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, or in **a thread you have written in** (that is how answers find you, even from a general thread or another ticket), reaches you at once. Nothing else does.\n- **Everything else waits.** `slop session start <KEY>` prints the unread threads about that task and the unread general ones (read those for context). `slop threads` lists them any time; `slop thread show <id>` reads one.\n- Your own messages never wake your own waiter.\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. Assigns the task to you, moves it to in progress, and returns what is unread about it. Read that first.\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. Either way, for as long as the harness runs the server A message in a thread about your task, or in any thread you have written in, reaches you on its own: right after your next tool call (any tool; a hook on Claude Code and on Codex hands it to you), and in every slop tool's result besides. There is nothing to poll and nothing to restart. When one arrives, act or answer with `say` `reply` and carry on.\n4. **Nothing to do but wait?** `session` `wait` blocks until the next message. Optional; most of the time you just keep working.\n5. **Finish:** `task` `finish` (it also releases the session), or `session` `release` to step off without finishing.\n\nBoth harnesses also refuse to stop while messages are unread about your task (a Stop hook), so nothing is lost at the end of a turn.\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; 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 its addressee\nsay post (subject, body, task: \"WEB-12\") a new thread about a task: whoever is live on it\nsay post (subject, body, to: \"Albert\") a new thread to one live agent, by the name session who shows\nsay post (subject, body) a general thread: for people, and for context\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\n**After your turn you stay reachable.** When your turn ends, the Stop hook sends you to `session` `wait` instead of stopping: you sit in that one call, costing nothing, until a message for you arrives; act or answer it, then stop again. If your person makes you not visible to teammates, `wait` says so and you may stop; until then nothing reaches you and you cannot post or reply.\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.\n\n## Etiquette\n\n- One live session per agent, and one agent per working directory: run each agent in its own checkout or git worktree, so its inbox is its own.\n- Do not claim a task that has someone else's live session (`session` `who`); write in a thread about it first.\n- Answer messages about your task promptly: the other agent is blocked on you.\n- Report progress in the plan's notes, not in threads. Threads are for talking to someone.\n- If you stop working on a task, end the session so nobody waits on you.\n";
3389
3389
 
3390
3390
  // skills/slop-working.md
3391
- var slop_working_default = '---\nname: slop-working\ndescription: The working loop for tasks on the board (claim, do, report, finish) and what to record where. Load at the start of any coding session in a repository linked with slop.\n---\n\n# The loop\n\n1. **What is there:** `slop task list --assignee me` (or `slop task list`). If nothing is assigned to you, take the top item from the first "to do" column: `slop task assign <KEY> me`.\n2. **Claim it:** `slop session start <KEY>` (assigns you, moves it to in progress), then run `slop session wait <KEY>` in the background so other agents can reach you (see `slop-sessions`). Read `slop task show <KEY>` fully, including the plan and its notes.\n3. **Do it.** Long work: follow `slop-planning`. Short work: just do it.\n4. **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.\n5. **Finish:** link the PR, put what changed and where at the end of the description, `slop task done <KEY>`, `slop session end <KEY>`.\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 |\n| A decision and its reason | the description (edit it) |\n| A question for the agent on a ticket | a thread about that ticket (`slop task ask KEY "\u2026"`) |\n| A note for people, or for whoever comes next | a general thread (`slop post "\u2026"`) |\n| A discovery, a hand-off | the description, or a new task if it is work |\n| A pull request, a design, a log | a link |\n| Loose working notes nobody needs to act on | the task\'s notes (`slop task notes`) |\n| Work you found but will not do now | a new task in backlog |\n\n# Etiquette\n\n- One task in progress at a time per agent. `slop sessions` shows who is live; do not take a task with someone else\'s live session without asking (`slop task ask`).\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';
3391
+ var slop_working_default = "---\nname: slop-working\ndescription: The working loop for tasks on the board (claim, do, report, finish) and what to record where. Load at the start of any coding session in a repository linked with slop.\n---\n\n# The loop\n\n1. **What is there:** `board` `list` with `mine`, or plain `list`. If nothing is assigned to you, take the top item of the first \"to do\" column.\n2. **Claim it:** `session` `claim`. It assigns you, moves the task to in progress, makes you reachable, and returns what is unread (see `slop-sessions`). Then `task` `show` and read all of it, plan and notes included.\n3. **Do it.** Long work: follow `slop-planning`. Short work: just do it.\n4. **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.\n5. **Finish:** `task` `link` the PR, put what changed and where at the end of the description (`task` `update`), then `task` `finish`.\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 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| 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";
3392
3392
 
3393
3393
  // src/platform/skills.ts
3394
3394
  var SKILLS = { "slop-cli": slop_cli_default, "slop-planning": slop_planning_default, "slop-sessions": slop_sessions_default, "slop-working": slop_working_default };
@@ -8438,7 +8438,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
8438
8438
  var NONE = 0;
8439
8439
  var ASSUMED = 1;
8440
8440
  var PROVEN = 2;
8441
- function isRecursive(inst, stack, resolve2) {
8441
+ function isRecursive(inst, stack, resolve3) {
8442
8442
  const cached2 = recursive.get(inst);
8443
8443
  if (cached2 !== void 0)
8444
8444
  return cached2 ? PROVEN : NONE;
@@ -8448,7 +8448,7 @@ function isRecursive(inst, stack, resolve2) {
8448
8448
  let result = NONE;
8449
8449
  const check2 = (child) => {
8450
8450
  if (result !== PROVEN && child?._zod) {
8451
- const answer = isRecursive(child, stack, resolve2);
8451
+ const answer = isRecursive(child, stack, resolve3);
8452
8452
  if (answer > result)
8453
8453
  result = answer;
8454
8454
  }
@@ -8459,7 +8459,7 @@ function isRecursive(inst, stack, resolve2) {
8459
8459
  const desc = Object.getOwnPropertyDescriptor(sh, key);
8460
8460
  if (spread && !desc.enumerable)
8461
8461
  continue;
8462
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve2) : NONE;
8462
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve3) : NONE;
8463
8463
  if (child > answer)
8464
8464
  answer = child;
8465
8465
  }
@@ -8523,7 +8523,7 @@ function isRecursive(inst, stack, resolve2) {
8523
8523
  break;
8524
8524
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
8525
8525
  case "lazy": {
8526
- const inner = def._cachedInner ?? (resolve2 ? inst._zod.innerType : void 0);
8526
+ const inner = def._cachedInner ?? (resolve3 ? inst._zod.innerType : void 0);
8527
8527
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
8528
8528
  break;
8529
8529
  }
@@ -23130,9 +23130,13 @@ var User = external_exports.object({
23130
23130
  avatarUrl: external_exports.string().nullable(),
23131
23131
  /** False for accounts that only ever signed in with a code. */
23132
23132
  hasPassword: external_exports.boolean(),
23133
+ /** New sessions of this person's agents start reachable by teammates (visible, messaged, messaging). */
23134
+ shareAgents: external_exports.boolean(),
23135
+ /** The first sign-in's welcome (the agent prompt) has been shown. */
23136
+ welcomed: external_exports.boolean(),
23133
23137
  createdAt: Timestamp
23134
23138
  });
23135
- var UpdateMeInput = external_exports.object({ name: external_exports.string().trim().min(1).max(80) });
23139
+ var UpdateMeInput = external_exports.object({ name: external_exports.string().trim().min(1).max(80).optional(), shareAgents: external_exports.boolean().optional(), welcomed: external_exports.literal(true).optional() });
23136
23140
  var AVATAR_CONTENT_TYPES = ["image/webp", "image/jpeg", "image/png"];
23137
23141
  var AVATAR_MAX_BYTES = 1e5;
23138
23142
  var SetAvatarInput = external_exports.object({
@@ -23209,12 +23213,14 @@ var Invitation = external_exports.object({
23209
23213
  });
23210
23214
  var InviteLink = external_exports.object({
23211
23215
  id: Id,
23216
+ /** Who it is for, as typed when it was made; shown in the members table so a link can be told from another. */
23217
+ label: external_exports.string().nullable(),
23212
23218
  role: OrgRole,
23213
23219
  createdAt: Timestamp,
23214
23220
  expiresAt: Timestamp.nullable(),
23215
23221
  uses: external_exports.number()
23216
23222
  });
23217
- var CreateInviteLinkInput = external_exports.object({ role: OrgRole.default("member"), expiresInHours: external_exports.number().int().min(1).max(24 * 30).nullable().default(24) });
23223
+ var CreateInviteLinkInput = external_exports.object({ role: OrgRole.default("member"), expiresInHours: external_exports.number().int().min(1).max(24 * 30).nullable().default(24), label: external_exports.string().trim().max(120).optional() });
23218
23224
  var CreatedInviteLink = InviteLink.extend({ url: external_exports.string() });
23219
23225
  var JoinPreview = external_exports.object({ org: external_exports.object({ id: Id, name: external_exports.string() }), role: OrgRole, alreadyMember: external_exports.boolean() });
23220
23226
  var MembersResult = external_exports.object({
@@ -23324,6 +23330,8 @@ var Thread = external_exports.object({
23324
23330
  taskId: Id.nullable(),
23325
23331
  taskKey: external_exports.string().nullable(),
23326
23332
  subject: external_exports.string(),
23333
+ /** Addressed to one live agent (by its session); replies reach it wherever it is. Null: to the task's agents, or general. */
23334
+ to: external_exports.object({ sessionId: external_exports.string(), agent: external_exports.string() }).nullable(),
23327
23335
  createdBy: UserRef,
23328
23336
  createdAt: Timestamp,
23329
23337
  lastMessageAt: Timestamp,
@@ -23332,12 +23340,14 @@ var Thread = external_exports.object({
23332
23340
  unread: external_exports.number(),
23333
23341
  last: external_exports.object({ author: UserRef, via: MessageVia, body: external_exports.string() }).nullable()
23334
23342
  });
23335
- var ThreadDetail = Thread.extend({ messages: external_exports.array(ThreadMessage) });
23343
+ var ThreadDetail = Thread.extend({ messages: external_exports.array(ThreadMessage), earlier: external_exports.number().default(0) });
23336
23344
  var body = external_exports.string().trim().min(1, "Say something").max(8e3);
23337
23345
  var CreateThreadInput = external_exports.object({
23338
23346
  subject: external_exports.string().trim().min(1, "Give it a subject").max(200),
23339
23347
  /** A task key or number; absent means general. */
23340
23348
  task: external_exports.string().trim().min(1).optional(),
23349
+ /** A live agent, by session id or name (as `session who` lists them). */
23350
+ to: external_exports.string().trim().min(1).max(200).optional(),
23341
23351
  body
23342
23352
  });
23343
23353
  var ReplyInput = external_exports.object({ body });
@@ -23440,14 +23450,35 @@ var TaskEventRecord = external_exports.object({
23440
23450
  });
23441
23451
 
23442
23452
  // ../contract/src/work/sessions.ts
23453
+ var AgentHarness = external_exports.enum(["claude", "codex", "other"]);
23443
23454
  var Session = external_exports.object({
23455
+ /** Derived from the person, the machine and the agent's conversation: the same across reconnects and resumes. */
23444
23456
  id: Id,
23445
23457
  /** Null: the agent is live in the project but on no task yet (presence). Replies in threads it is part of still reach it. */
23446
23458
  taskId: Id.nullable(),
23447
23459
  taskKey: external_exports.string().nullable(),
23448
23460
  user: UserRef,
23449
23461
  machine: external_exports.object({ id: Id, name: external_exports.string() }),
23450
- startedAt: Timestamp
23462
+ /** The agent itself: a generated name (see agent-names) and which harness runs it. */
23463
+ agent: external_exports.object({ name: external_exports.string(), harness: AgentHarness.nullable() }),
23464
+ /** Where it runs, to tell two agents under one person apart. */
23465
+ where: external_exports.object({ directory: external_exports.string().nullable(), branch: external_exports.string().nullable() }),
23466
+ startedAt: Timestamp,
23467
+ /** "reconnecting": its socket dropped and the room is holding the session open for it to come back. */
23468
+ state: external_exports.enum(["live", "reconnecting"]),
23469
+ /**
23470
+ * Reachable by teammates: listed to them, messaged by them and by their agents, and able to message. Off, the agent
23471
+ * still works the board but talks to nobody and is listed only to its own person. Starts from the person's default.
23472
+ */
23473
+ reachable: external_exports.boolean().default(true)
23474
+ });
23475
+ var ReachableInput = external_exports.object({ on: external_exports.boolean() });
23476
+ var SessionClaimInput = external_exports.object({
23477
+ /** Stable for one conversation of the agent (the harness's own session id), so a resumed chat is the same session and keeps its name. */
23478
+ conversation: external_exports.string().trim().min(1).max(200).optional(),
23479
+ harness: AgentHarness.nullable().default(null),
23480
+ directory: external_exports.string().trim().max(200).nullable().default(null),
23481
+ branch: external_exports.string().trim().max(200).nullable().default(null)
23451
23482
  });
23452
23483
  var ConnectTicket = external_exports.object({ ticket: external_exports.string(), path: external_exports.string() });
23453
23484
  var AskInput = external_exports.object({ body: external_exports.string().trim().min(1, "Say something").max(4e3) });
@@ -23455,13 +23486,127 @@ var SessionPush = external_exports.discriminatedUnion("type", [
23455
23486
  external_exports.object({ type: external_exports.literal("hello"), session: Session }),
23456
23487
  external_exports.object({ type: external_exports.literal("event"), record: TaskEventRecord }),
23457
23488
  /** A message in a thread about the task this session is on. */
23458
- external_exports.object({ type: external_exports.literal("thread"), thread: external_exports.object({ id: Id, subject: external_exports.string(), taskKey: external_exports.string().nullable() }), message: ThreadMessage }),
23489
+ external_exports.object({ type: external_exports.literal("thread"), thread: external_exports.object({ id: Id, subject: external_exports.string(), taskKey: external_exports.string().nullable(), to: external_exports.string().nullable().optional() }), message: ThreadMessage }),
23459
23490
  /** The room is closing this session (someone ran `slop session end`). */
23460
23491
  external_exports.object({ type: external_exports.literal("end") }),
23492
+ /** Reachability was switched from the web. Off releases a waiting agent: nothing can reach it any more. */
23493
+ external_exports.object({ type: external_exports.literal("reachable"), on: external_exports.boolean() }),
23461
23494
  /** To board watchers: something in the project changed; refetch. */
23462
23495
  external_exports.object({ type: external_exports.literal("changed"), scope: external_exports.string() })
23463
23496
  ]);
23464
23497
 
23498
+ // ../contract/src/work/agent-names.ts
23499
+ var AGENT_NAMES = [
23500
+ "Albert",
23501
+ "Alma",
23502
+ "Amos",
23503
+ "Astrid",
23504
+ "August",
23505
+ "Beatrix",
23506
+ "Bruno",
23507
+ "Cedric",
23508
+ "Clara",
23509
+ "Cosmo",
23510
+ "Dahlia",
23511
+ "Dexter",
23512
+ "Edie",
23513
+ "Elio",
23514
+ "Ember",
23515
+ "Felix",
23516
+ "Fern",
23517
+ "Flora",
23518
+ "Gus",
23519
+ "Hazel",
23520
+ "Hugo",
23521
+ "Ida",
23522
+ "Ingrid",
23523
+ "Iris",
23524
+ "Jasper",
23525
+ "Juno",
23526
+ "Kit",
23527
+ "Leo",
23528
+ "Lila",
23529
+ "Linus",
23530
+ "Lotte",
23531
+ "Mabel",
23532
+ "Marlow",
23533
+ "Milo",
23534
+ "Minnie",
23535
+ "Nell",
23536
+ "Nico",
23537
+ "Nina",
23538
+ "Odette",
23539
+ "Olive",
23540
+ "Oscar",
23541
+ "Otto",
23542
+ "Pearl",
23543
+ "Percy",
23544
+ "Pip",
23545
+ "Quill",
23546
+ "Remy",
23547
+ "Rosa",
23548
+ "Rufus",
23549
+ "Sable",
23550
+ "Silas",
23551
+ "Sky",
23552
+ "Sol",
23553
+ "Tess",
23554
+ "Theo",
23555
+ "Tilly",
23556
+ "Ulla",
23557
+ "Vera",
23558
+ "Vito",
23559
+ "Wren",
23560
+ "Xavi",
23561
+ "Yara",
23562
+ "Zed",
23563
+ "Zora",
23564
+ "Arlo",
23565
+ "Bea",
23566
+ "Cyrus",
23567
+ "Della",
23568
+ "Enzo",
23569
+ "Frida",
23570
+ "Gilda",
23571
+ "Hal",
23572
+ "Imre",
23573
+ "Joss",
23574
+ "Kai",
23575
+ "Lena",
23576
+ "Mona",
23577
+ "Nate",
23578
+ "Opal",
23579
+ "Pia",
23580
+ "Rae",
23581
+ "Sacha",
23582
+ "Tove",
23583
+ "Uma",
23584
+ "Vince",
23585
+ "Willa",
23586
+ "Yuki",
23587
+ "Zelda",
23588
+ "Ansel",
23589
+ "Birdie",
23590
+ "Cleo",
23591
+ "Dov",
23592
+ "Esme",
23593
+ "Fitz",
23594
+ "Greta",
23595
+ "Hollis",
23596
+ "Inez",
23597
+ "Jude",
23598
+ "Kip",
23599
+ "Lark"
23600
+ ];
23601
+ function agentNameFor(key) {
23602
+ let hash2 = 2166136261;
23603
+ for (let index = 0; index < key.length; index += 1) {
23604
+ hash2 ^= key.charCodeAt(index);
23605
+ hash2 = Math.imul(hash2, 16777619) >>> 0;
23606
+ }
23607
+ return AGENT_NAMES[hash2 % AGENT_NAMES.length] ?? AGENT_NAMES[0];
23608
+ }
23609
+
23465
23610
  // ../contract/src/work/bindings.ts
23466
23611
  var RepoInfo = external_exports.object({
23467
23612
  /**
@@ -23520,7 +23665,7 @@ function resolveSession() {
23520
23665
 
23521
23666
  // src/platform/session-file.ts
23522
23667
  import { createHash } from "crypto";
23523
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
23668
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
23524
23669
  import { dirname as dirname2, join as join3 } from "path";
23525
23670
  var sessionsDir = () => join3(dirname2(configPath()), "sessions");
23526
23671
  var realCwd = (cwd) => {
@@ -23540,6 +23685,21 @@ function rememberedSession(cwd) {
23540
23685
  const path3 = sessionFile(cwd);
23541
23686
  return existsSync3(path3) ? readFileSync3(path3, "utf8").trim() || null : null;
23542
23687
  }
23688
+ var conversationFile = (cwd) => join3(sessionsDir(), `${createHash("sha256").update(realCwd(cwd)).digest("hex").slice(0, 16)}.conversation.json`);
23689
+ function rememberConversation(cwd, conversation) {
23690
+ mkdirSync3(sessionsDir(), { recursive: true });
23691
+ writeFileSync3(conversationFile(cwd), JSON.stringify(conversation));
23692
+ }
23693
+ function rememberedConversation(cwd, notBefore) {
23694
+ const path3 = conversationFile(cwd);
23695
+ try {
23696
+ if (statSync(path3).mtimeMs < notBefore) return null;
23697
+ const parsed = JSON.parse(readFileSync3(path3, "utf8"));
23698
+ return typeof parsed.id === "string" && parsed.id ? { id: parsed.id, harness: parsed.harness ?? "other" } : null;
23699
+ } catch {
23700
+ return null;
23701
+ }
23702
+ }
23543
23703
 
23544
23704
  // src/platform/errors.ts
23545
23705
  var CliError = class extends Error {
@@ -23775,7 +23935,7 @@ This repository is linked to a Slopware Tasks board. Use the \`slop\` CLI to kee
23775
23935
  - Before starting tracked work: \`slop task list\`, then \`slop task start <KEY>\`.
23776
23936
  - When it is finished and verified: \`slop task done <KEY>\`.
23777
23937
  - Descriptions are Markdown and are shown in full on the board. Write real ones: context, file paths, what done means. Pipe long ones in with \`-d -\`.
23778
- - Best: \`slop mcp install\` once, then use the slop MCP tools: \`claim_task\`, work, \`wait_for_message\` at every stopping point (it holds until someone writes to you), \`reply\`. Without MCP: \`slop session start <KEY>\` and \`slop session wait <KEY>\` in the background (the exit is the message; relaunch at once). Talk through threads: \`slop task ask <KEY> "\u2026"\` reaches whoever is live on that task, \`slop thread reply <id> "\u2026"\` answers, \`slop threads\` lists them; general threads are for people and for context. Method: \`slop skill slop-sessions\`.
23938
+ - Agents use the six slop MCP tools for everything on the board (\`slop mcp install\` once per machine): \`board\`, \`task\`, \`plan\`, \`session\`, \`threads\`, \`say\`, each with an \`action\`. You have a name (session start tells you; \`session who\` lists everyone's); \`say post\` with \`to\` reaches one agent by name. Messages for you reach you after your next tool call. The \`slop\` command is for people and scripts. Talk through threads: \`slop task ask <KEY> "\u2026"\` reaches whoever is live on that task, \`slop thread reply <id> "\u2026"\` answers, \`slop threads\` lists them; general threads are for people and for context. Method: \`slop skill slop-sessions\`.
23779
23939
  - 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.
23780
23940
  - Everything is editable: \`slop task update <KEY> --title \u2026 --severity \u2026 --status \u2026 --assignee \u2026\`. Assign with \`slop task assign <KEY> <email|me|none>\`; see who exists with \`slop members\`. \`slop task list --mine\` shows your user's tasks.
23781
23941
  - Add \`--json\` to any command for machine-readable output. \`slop task --help\` lists everything.
@@ -23805,6 +23965,10 @@ function findRepo() {
23805
23965
  const root = basename(commonDir) === ".git" ? resolve(commonDir, "..") : worktree ?? commonDir;
23806
23966
  return { root, worktree: worktree ?? root, linkPath: join5(commonDir, "slop.json") };
23807
23967
  }
23968
+ function currentBranch(cwd = process.cwd()) {
23969
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
23970
+ return branch && branch !== "HEAD" ? branch : null;
23971
+ }
23808
23972
  function requireRepo() {
23809
23973
  const repo = findRepo();
23810
23974
  if (!repo) throw new CliError("Not inside a git repository", "cd into your repository, or pass --project KEY", "no_repo");
@@ -23979,24 +24143,26 @@ linked to ${link.projectKey} ${link.projectName} (${link.baseUrl})`
23979
24143
  }
23980
24144
 
23981
24145
  // src/commands/mcp.ts
23982
- import { execFileSync as execFileSync3 } from "child_process";
23983
- import { createHash as createHash4 } from "crypto";
23984
- import { appendFileSync, existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync9, readdirSync, realpathSync as realpathSync2, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
23985
- import { dirname as dirname4 } from "path";
24146
+ import { execFileSync as execFileSync3, spawn as spawn2 } from "child_process";
24147
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
24148
+ import "path";
23986
24149
  import { homedir as homedir3 } from "os";
23987
- import { join as join6 } from "path";
24150
+ import { join as join8 } from "path";
23988
24151
 
23989
24152
  // src/platform/live.ts
24153
+ var PING_EVERY_MS = 25e3;
24154
+ var PONG_WITHIN_MS = 1e4;
23990
24155
  async function waitForMessage(api, projectId, taskKey, options = {}) {
23991
- const ticket = await api.post(`/projects/${projectId}/tasks/${encodeURIComponent(taskKey)}/session/ticket`);
24156
+ const ticket = await api.post(`/projects/${projectId}/tasks/${encodeURIComponent(taskKey)}/session/ticket`, options.claim ?? {});
23992
24157
  const socket = new WebSocket(`${api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
23993
- const outcome = await new Promise((resolve2) => {
23994
- const timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => resolve2({ kind: "timeout" }), options.timeoutMs) : null;
24158
+ const outcome = await new Promise((resolve3) => {
24159
+ const timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => resolve3({ kind: "timeout" }), options.timeoutMs) : null;
23995
24160
  const done = (result) => {
23996
24161
  if (timer) clearTimeout(timer);
23997
- resolve2(result);
24162
+ resolve3(result);
23998
24163
  };
23999
24164
  socket.addEventListener("message", (event) => {
24165
+ if (String(event.data) === "pong") return;
24000
24166
  const push = JSON.parse(String(event.data));
24001
24167
  if (push.type === "hello") {
24002
24168
  api.sessionId = push.session.id;
@@ -24018,28 +24184,33 @@ async function waitForMessage(api, projectId, taskKey, options = {}) {
24018
24184
  if (outcome.kind !== "message") rememberSession(process.cwd(), null);
24019
24185
  if (outcome.kind === "message" || outcome.kind === "timeout") {
24020
24186
  socket.close(1e3, outcome.kind === "message" ? "handled" : "timeout");
24021
- await new Promise((resolve2) => {
24022
- socket.addEventListener("close", () => resolve2(), { once: true });
24023
- setTimeout(resolve2, 500);
24187
+ await new Promise((resolve3) => {
24188
+ socket.addEventListener("close", () => resolve3(), { once: true });
24189
+ setTimeout(resolve3, 500);
24024
24190
  });
24025
24191
  }
24026
24192
  return outcome;
24027
24193
  }
24028
24194
  var HeldSession = class {
24029
- constructor(api, projectId, taskKey, onMessage) {
24195
+ constructor(api, projectId, taskKey, hooks) {
24030
24196
  this.api = api;
24031
24197
  this.projectId = projectId;
24032
24198
  this.taskKey = taskKey;
24033
- this.onMessage = onMessage;
24199
+ this.hooks = hooks;
24034
24200
  }
24035
24201
  api;
24036
24202
  projectId;
24037
24203
  taskKey;
24038
- onMessage;
24204
+ hooks;
24039
24205
  socket = null;
24040
24206
  stopped = false;
24041
24207
  timer = null;
24208
+ heartbeat = null;
24209
+ pongDue = null;
24042
24210
  attempt = 0;
24211
+ connected = 0;
24212
+ session = null;
24213
+ /** Whoever is sitting in `session wait`. Handed the next message, or null when standby is switched off under them. */
24043
24214
  waiters = /* @__PURE__ */ new Set();
24044
24215
  async start() {
24045
24216
  await this.connect();
@@ -24047,30 +24218,48 @@ var HeldSession = class {
24047
24218
  async connect() {
24048
24219
  if (this.stopped) return;
24049
24220
  try {
24050
- const ticket = await this.api.post(this.taskKey ? `/projects/${this.projectId}/tasks/${encodeURIComponent(this.taskKey)}/session/ticket` : `/projects/${this.projectId}/session/ticket`);
24221
+ const ticket = await this.api.post(this.taskKey ? `/projects/${this.projectId}/tasks/${encodeURIComponent(this.taskKey)}/session/ticket` : `/projects/${this.projectId}/session/ticket`, this.hooks.claim());
24051
24222
  const socket = new WebSocket(`${this.api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
24052
24223
  this.socket = socket;
24053
24224
  socket.addEventListener("open", () => {
24054
24225
  this.attempt = 0;
24226
+ this.startHeartbeat(socket);
24055
24227
  });
24056
24228
  socket.addEventListener("message", (event) => {
24057
- const push = JSON.parse(String(event.data));
24229
+ const data = String(event.data);
24230
+ if (data === "pong") {
24231
+ if (this.pongDue) clearTimeout(this.pongDue);
24232
+ this.pongDue = null;
24233
+ return;
24234
+ }
24235
+ const push = JSON.parse(data);
24058
24236
  if (push.type === "hello") {
24059
24237
  this.api.sessionId = push.session.id;
24238
+ this.session = push.session;
24239
+ this.taskKey = push.session.taskKey;
24060
24240
  rememberSession(process.cwd(), push.session.id);
24241
+ this.connected += 1;
24242
+ if (this.connected > 1) void this.hooks.onReconnect?.(push.session);
24061
24243
  return;
24062
24244
  }
24063
24245
  if (push.type === "end") {
24064
24246
  this.stopped = true;
24065
24247
  return;
24066
24248
  }
24249
+ if (push.type === "reachable") {
24250
+ if (this.session) this.session = { ...this.session, reachable: push.on };
24251
+ if (!push.on) for (const waiter of [...this.waiters]) waiter(null);
24252
+ return;
24253
+ }
24067
24254
  if (push.type !== "thread") return;
24068
24255
  const delivered = { thread: push.thread, message: push.message };
24069
- for (const waiter of this.waiters) waiter(delivered);
24070
- if (this.waiters.size === 0) this.onMessage(delivered);
24256
+ const taken = this.waiters.size > 0;
24257
+ for (const waiter of [...this.waiters]) waiter(delivered);
24258
+ if (!taken) this.hooks.onMessage(delivered);
24071
24259
  });
24072
24260
  socket.addEventListener("close", () => {
24073
- this.socket = null;
24261
+ this.stopHeartbeat();
24262
+ if (this.socket === socket) this.socket = null;
24074
24263
  if (!this.stopped) this.schedule();
24075
24264
  });
24076
24265
  socket.addEventListener("error", () => {
@@ -24079,6 +24268,26 @@ var HeldSession = class {
24079
24268
  this.schedule();
24080
24269
  }
24081
24270
  }
24271
+ /** A ping every 25s; no pong within 10s means the socket is dead however open it looks. Close it and come back. */
24272
+ startHeartbeat(socket) {
24273
+ this.stopHeartbeat();
24274
+ this.heartbeat = setInterval(() => {
24275
+ if (socket.readyState !== WebSocket.OPEN) return;
24276
+ socket.send("ping");
24277
+ if (!this.pongDue) {
24278
+ this.pongDue = setTimeout(() => {
24279
+ this.pongDue = null;
24280
+ socket.close(4e3, "no pong");
24281
+ }, PONG_WITHIN_MS);
24282
+ }
24283
+ }, PING_EVERY_MS);
24284
+ }
24285
+ stopHeartbeat() {
24286
+ if (this.heartbeat) clearInterval(this.heartbeat);
24287
+ if (this.pongDue) clearTimeout(this.pongDue);
24288
+ this.heartbeat = null;
24289
+ this.pongDue = null;
24290
+ }
24082
24291
  schedule() {
24083
24292
  this.attempt += 1;
24084
24293
  this.timer = setTimeout(() => void this.connect(), Math.min(3e4, 1e3 * 2 ** Math.min(this.attempt, 5)));
@@ -24088,10 +24297,36 @@ var HeldSession = class {
24088
24297
  this.api.sessionId = null;
24089
24298
  rememberSession(process.cwd(), null);
24090
24299
  if (this.timer) clearTimeout(this.timer);
24300
+ this.stopHeartbeat();
24091
24301
  this.socket?.close(1e3, "released");
24092
24302
  }
24093
24303
  };
24094
24304
 
24305
+ // src/platform/agent-identity.ts
24306
+ import { randomUUID as randomUUID3 } from "crypto";
24307
+ import { basename as basename2, resolve as resolve2 } from "path";
24308
+ var processConversation = randomUUID3();
24309
+ var startedAt = Date.now();
24310
+ function agentIdentity(cwd, fallback = "process") {
24311
+ const conversation = rememberedConversation(cwd, startedAt - 12e4);
24312
+ const repo = findRepo();
24313
+ return {
24314
+ conversation: conversation?.id ?? (fallback === "process" ? processConversation : `dir:${resolve2(cwd)}`),
24315
+ harness: conversation?.harness ?? null,
24316
+ directory: repo ? basename2(repo.worktree) : basename2(cwd),
24317
+ branch: currentBranch(cwd)
24318
+ };
24319
+ }
24320
+
24321
+ // src/platform/bounded.ts
24322
+ var RESULT_CAP = 16e3;
24323
+ function bounded(result) {
24324
+ if (result.length <= RESULT_CAP) return result;
24325
+ return `${result.slice(0, RESULT_CAP)}
24326
+
24327
+ [cut: ${result.length - RESULT_CAP} more characters; ask for less with limit, offset or before]`;
24328
+ }
24329
+
24095
24330
  // src/platform/mcp.ts
24096
24331
  import { createInterface } from "readline";
24097
24332
  function serveMcp(tools2, info) {
@@ -24387,13 +24622,59 @@ ${taskUrl(api.baseUrl, project.id, created)}`);
24387
24622
  return task;
24388
24623
  }
24389
24624
 
24625
+ // src/platform/update-check.ts
24626
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
24627
+ import { dirname as dirname4, join as join6 } from "path";
24628
+ var CHECK_EVERY_MS = 60 * 60 * 1e3;
24629
+ var REGISTRY = "https://registry.npmjs.org/slopwaresystems/latest";
24630
+ var cacheFile = () => join6(dirname4(configPath()), "update-check.json");
24631
+ function readCache() {
24632
+ try {
24633
+ return JSON.parse(readFileSync8(cacheFile(), "utf8"));
24634
+ } catch {
24635
+ return { checkedAt: 0, latest: null };
24636
+ }
24637
+ }
24638
+ function writeCache(cache) {
24639
+ mkdirSync5(dirname4(cacheFile()), { recursive: true });
24640
+ writeFileSync6(cacheFile(), JSON.stringify(cache));
24641
+ }
24642
+ function isNewer(a, b) {
24643
+ const parse3 = (version2) => version2.split(".").map((part) => Number.parseInt(part, 10));
24644
+ const [x, y] = [parse3(a), parse3(b)];
24645
+ if (x.length !== 3 || y.length !== 3 || [...x, ...y].some((n) => Number.isNaN(n))) return false;
24646
+ for (let i = 0; i < 3; i += 1) {
24647
+ if (x[i] !== y[i]) return x[i] > y[i];
24648
+ }
24649
+ return false;
24650
+ }
24651
+ var inFlight = false;
24652
+ function refresh() {
24653
+ const cache = readCache();
24654
+ if (inFlight || Date.now() - cache.checkedAt < CHECK_EVERY_MS) return;
24655
+ inFlight = true;
24656
+ writeCache({ ...cache, checkedAt: Date.now() });
24657
+ fetch(REGISTRY, { signal: AbortSignal.timeout(4e3), headers: { accept: "application/json" } }).then(async (response) => response.ok ? (await response.json()).version ?? null : null).then((latest) => {
24658
+ if (latest) writeCache({ checkedAt: Date.now(), latest });
24659
+ }).catch(() => void 0).finally(() => {
24660
+ inFlight = false;
24661
+ });
24662
+ }
24663
+ function updateNotice(current) {
24664
+ refresh();
24665
+ const { latest } = readCache();
24666
+ if (!latest || !isNewer(latest, current)) return null;
24667
+ return `Update available: slopwaresystems ${latest} (this is ${current}). Ask your person to run: npm i -g slopwaresystems && slop mcp install`;
24668
+ }
24669
+ var updateChecksEnabled = () => !existsSync6(join6(dirname4(configPath()), "no-update-check")) && process.env.SLOP_NO_UPDATE_CHECK !== "1";
24670
+
24390
24671
  // src/commands/threads.ts
24391
- import { readFileSync as readFileSync8 } from "fs";
24392
- var readText2 = (value) => value === "-" ? readFileSync8(0, "utf8").trim() : value;
24672
+ import { readFileSync as readFileSync9 } from "fs";
24673
+ var readText2 = (value) => value === "-" ? readFileSync9(0, "utf8").trim() : value;
24393
24674
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
24394
24675
  var who = (message) => `${message.author.name}${message.via === "agent" ? ` (agent${message.machine ? ` \xB7 ${message.machine}` : ""})` : ""}`;
24395
24676
  function threadLine(thread) {
24396
- const scope = thread.taskKey ?? "general";
24677
+ const scope = [thread.taskKey ?? (thread.to ? null : "general"), thread.to ? `to ${thread.to.agent}` : null].filter(Boolean).join(", ");
24397
24678
  const unread = thread.unread > 0 ? ` ${thread.unread} unread` : "";
24398
24679
  const last = thread.last ? `
24399
24680
  ${who(thread.last)}: ${thread.last.body.split("\n")[0]}` : "";
@@ -24407,9 +24688,10 @@ function threadBlock(thread) {
24407
24688
  return [`${thread.subject} [${thread.taskKey ?? "general"}] ${thread.id}`, "", ...thread.messages.map(messageBlock)].join("\n\n").replace("\n\n\n", "\n\n");
24408
24689
  }
24409
24690
  function pushBlock(push) {
24410
- return `Thread ${push.thread.id} [${push.thread.taskKey ?? "general"}] ${push.thread.subject}
24691
+ const scope = [push.thread.taskKey ?? (push.thread.to ? null : "general"), push.thread.to ? `to ${push.thread.to}` : null].filter(Boolean).join(", ");
24692
+ return `Thread ${push.thread.id} [${scope}] ${push.thread.subject}
24411
24693
  ${messageBlock(push.message)}
24412
- Reply: slop thread reply ${push.thread.id} "\u2026"`;
24694
+ Reply: say reply (thread ${push.thread.id})`;
24413
24695
  }
24414
24696
  function registerThreadCommands(program3, task) {
24415
24697
  const withProject = (command) => command.option("-p, --project <key>", "project key (default: the project this repository is linked to)");
@@ -24424,10 +24706,10 @@ function registerThreadCommands(program3, task) {
24424
24706
  emit(threads, () => threads.length === 0 ? "No threads." : threads.map(threadLine).join("\n"));
24425
24707
  });
24426
24708
  const thread = program3.command("thread").description("One thread: start, read, reply.");
24427
- withProject(thread.command("new <subject>")).description('Start a thread. General by default; --task makes it about a task (and reaches whoever is live on it). The first message is -m, or "-" for stdin.').requiredOption("-m, --message <text>", 'the first message; "-" reads stdin').option("-t, --task <key>", "about this task").action(async (subject, options) => {
24709
+ withProject(thread.command("new <subject>")).description('Start a thread. General by default; --task makes it about a task (and reaches whoever is live on it). The first message is -m, or "-" for stdin.').requiredOption("-m, --message <text>", 'the first message; "-" reads stdin').option("-t, --task <key>", "about this task").option("--to <agent>", "to one live agent, by name or session id (slop sessions)").action(async (subject, options) => {
24428
24710
  const api = authedApi();
24429
24711
  const project = await resolveProject(api, options.project);
24430
- const result = await api.post(`/projects/${project.id}/threads`, { subject, task: options.task, body: readText2(options.message) });
24712
+ const result = await api.post(`/projects/${project.id}/threads`, { subject, task: options.task, to: options.to, body: readText2(options.message) });
24431
24713
  emit(result, () => started(result));
24432
24714
  });
24433
24715
  withProject(thread.command("show <id>")).description("Read a thread (and mark it read).").action(async (id, options) => {
@@ -24462,15 +24744,13 @@ var subjectOf = (body2) => {
24462
24744
  if (!line) throw new CliError("Say something", "the first line becomes the subject");
24463
24745
  return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
24464
24746
  };
24465
- var started = (result) => result.thread.taskKey ? result.delivered ? `Started ${result.thread.id} on ${result.thread.taskKey}; reached ${result.delivered} live session${result.delivered === 1 ? "" : "s"}.` : `Started ${result.thread.id} on ${result.thread.taskKey}. Nobody is live on it; they see it when they start.` : `Started ${result.thread.id} (general). People see it on the web; agents at their next session start.`;
24747
+ var started = (result) => result.thread.to ? `Started ${result.thread.id} to ${result.thread.to.agent}${result.delivered ? "; it has it now." : "; it reads it when it is back."}` : result.thread.taskKey ? result.delivered ? `Started ${result.thread.id} on ${result.thread.taskKey}; reached ${result.delivered} live session${result.delivered === 1 ? "" : "s"}.` : `Started ${result.thread.id} on ${result.thread.taskKey}. Nobody is live on it; they see it when they start.` : `Started ${result.thread.id} (general). People see it on the web; agents at their next session start.`;
24466
24748
 
24467
- // src/commands/mcp.ts
24468
- var str = (description2) => ({ type: "string", description: description2 });
24469
- var int2 = (description2) => ({ type: "integer", description: description2 });
24470
- var schema = (properties, required2 = []) => ({ type: "object", properties, required: required2, additionalProperties: false });
24471
- var PROJECT = str("Project key. Defaults to the project this repository is linked to.");
24472
- var taskPath = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24473
- var inboxDir = () => join6(dirname4(configPath()), "inbox");
24749
+ // src/platform/inbox.ts
24750
+ import { createHash as createHash4 } from "crypto";
24751
+ import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync10, realpathSync as realpathSync2, renameSync, rmSync as rmSync4 } from "fs";
24752
+ import { dirname as dirname5, join as join7 } from "path";
24753
+ var inboxDir = () => join7(dirname5(configPath()), "inbox");
24474
24754
  var realCwd2 = (cwd) => {
24475
24755
  try {
24476
24756
  return realpathSync2(cwd);
@@ -24479,19 +24759,49 @@ var realCwd2 = (cwd) => {
24479
24759
  }
24480
24760
  };
24481
24761
  var inboxKey = (cwd) => createHash4("sha256").update(realCwd2(cwd)).digest("hex").slice(0, 16);
24482
- var inboxFile = (cwd) => join6(inboxDir(), `${inboxKey(cwd)}.jsonl`);
24762
+ var inboxFile = (cwd) => join7(inboxDir(), `${inboxKey(cwd)}.jsonl`);
24483
24763
  function inboxWrite(cwd, push) {
24484
- mkdirSync5(inboxDir(), { recursive: true });
24764
+ mkdirSync6(inboxDir(), { recursive: true });
24485
24765
  appendFileSync(inboxFile(cwd), `${JSON.stringify(push)}
24486
24766
  `);
24487
24767
  }
24488
24768
  function inboxDrain(cwd) {
24489
24769
  const path3 = inboxFile(cwd);
24490
- if (!existsSync6(path3)) return [];
24491
- const lines = readFileSync9(path3, "utf8").split("\n").filter(Boolean);
24492
- rmSync4(path3, { force: true });
24493
- return lines.map((line) => JSON.parse(line));
24770
+ if (!existsSync7(path3)) return [];
24771
+ const taken = `${path3}.${process.pid}.${Date.now()}.draining`;
24772
+ try {
24773
+ renameSync(path3, taken);
24774
+ } catch {
24775
+ return [];
24776
+ }
24777
+ const lines = readFileSync10(taken, "utf8").split("\n").filter(Boolean);
24778
+ rmSync4(taken, { force: true });
24779
+ return lines.flatMap((line) => {
24780
+ try {
24781
+ return [JSON.parse(line)];
24782
+ } catch {
24783
+ return [];
24784
+ }
24785
+ });
24494
24786
  }
24787
+
24788
+ // src/commands/mcp.ts
24789
+ var str = (description2) => ({ type: "string", description: description2 });
24790
+ var int2 = (description2) => ({ type: "integer", description: description2 });
24791
+ var schema = (properties, required2 = []) => ({ type: "object", properties, required: required2, additionalProperties: false });
24792
+ var PROJECT = str("Project key. Defaults to the project this repository is linked to.");
24793
+ var taskPath = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24794
+ var sessionLine = (session, mine = null) => [
24795
+ // Optional chaining on purpose: a server from before agents had names answers without these, and a tool must not crash on it.
24796
+ (session.agent?.name ?? "agent") + (session.id === mine ? " (you)" : ""),
24797
+ session.taskKey ?? "(no task)",
24798
+ session.user.name,
24799
+ session.machine.name,
24800
+ session.where?.directory ? `${session.where.directory}${session.where.branch ? `@${session.where.branch}` : ""}` : "-",
24801
+ `since ${session.startedAt}`,
24802
+ session.state === "reconnecting" ? "(reconnecting)" : "",
24803
+ session.reachable ? "" : "(not visible to teammates)"
24804
+ ].filter(Boolean).join(" ");
24495
24805
  async function digest(api, projectId, taskKey) {
24496
24806
  const [about, mine, general] = await Promise.all([
24497
24807
  taskKey ? api.get(`/projects/${projectId}/threads?task=${encodeURIComponent(taskKey)}&unread=1&read=1`) : Promise.resolve([]),
@@ -24505,14 +24815,31 @@ async function digest(api, projectId, taskKey) {
24505
24815
  if (general.length) lines.push("Unread general threads (context; nobody has to answer):", ...general.map(threadLine));
24506
24816
  return lines.length ? lines.join("\n") : "Nothing unread.";
24507
24817
  }
24508
- function tools() {
24818
+ async function catchUp(api, projectId, taskKey, cwd) {
24819
+ const [mine, about] = await Promise.all([
24820
+ api.get(`/projects/${projectId}/threads?participant=1&unread=1`),
24821
+ taskKey ? api.get(`/projects/${projectId}/threads?task=${encodeURIComponent(taskKey)}&unread=1`) : Promise.resolve([])
24822
+ ]);
24823
+ const seen = /* @__PURE__ */ new Set();
24824
+ for (const thread of [...about, ...mine]) {
24825
+ if (seen.has(thread.id) || thread.unread === 0) continue;
24826
+ seen.add(thread.id);
24827
+ const detail = await api.get(`/projects/${projectId}/threads/${thread.id}?read=0`);
24828
+ for (const message of detail.messages.slice(-thread.unread)) inboxWrite(cwd, { thread: { id: detail.id, subject: detail.subject, taskKey: detail.taskKey, to: detail.to?.agent ?? null }, message });
24829
+ }
24830
+ }
24831
+ function handlers() {
24509
24832
  const api = authedApi();
24510
24833
  const resolveOnly = (args) => resolveProject(api, args.project);
24511
24834
  let held = null;
24512
24835
  const hold = async (projectId, taskKey) => {
24513
24836
  if (held && held.projectId === projectId && held.taskKey === taskKey) return;
24514
24837
  held?.stop();
24515
- held = new HeldSession(api, projectId, taskKey, (push) => inboxWrite(process.cwd(), push));
24838
+ held = new HeldSession(api, projectId, taskKey, {
24839
+ onMessage: (push) => inboxWrite(process.cwd(), push),
24840
+ claim: () => agentIdentity(process.cwd()),
24841
+ onReconnect: (session) => catchUp(api, projectId, session.taskKey, process.cwd()).catch(() => void 0)
24842
+ });
24516
24843
  await held.start();
24517
24844
  };
24518
24845
  const project = async (args) => {
@@ -24526,26 +24853,28 @@ function tools() {
24526
24853
  return [
24527
24854
  {
24528
24855
  name: "wait_for_message",
24529
- description: "Optional. Block until the next message in a thread about the task you hold (claim_task first), or until `minutes` pass. You do not need this to be reached: messages arrive on their own after your next tool call. Use it only when you have nothing else to do and want to sit and wait.",
24530
- inputSchema: schema({ task: str("Task key, e.g. WEB-12"), minutes: int2("How long to wait before returning empty (default 10)"), project: PROJECT }, ["task"]),
24856
+ description: "Block until the next message for you (on your task, in a thread you are in, or addressed to you), or until `minutes` pass. After your turn the Stop hook sends you here, so a message from the web wakes you; nothing runs while you wait.",
24857
+ inputSchema: schema({ task: str("Task key to go live on first (optional)"), minutes: int2("Give up after (default 300)"), project: PROJECT }),
24531
24858
  run: async (args) => {
24532
24859
  const target = await project(args);
24533
- await hold(target.id, String(args.task));
24860
+ if (args.task) await hold(target.id, String(args.task));
24534
24861
  const queued = inboxDrain(process.cwd());
24535
24862
  if (queued.length) return queued.map(pushBlock).join("\n\n");
24536
- const minutes = typeof args.minutes === "number" ? args.minutes : 10;
24537
- const push = await new Promise((resolve2) => {
24863
+ const minutes = typeof args.minutes === "number" ? args.minutes : 300;
24864
+ const outcome = await new Promise((resolve3) => {
24538
24865
  const waiter = (delivered) => {
24539
24866
  held?.waiters.delete(waiter);
24540
- resolve2(delivered);
24867
+ resolve3({ push: delivered, released: delivered === null });
24541
24868
  };
24542
24869
  held?.waiters.add(waiter);
24543
24870
  setTimeout(() => {
24544
24871
  held?.waiters.delete(waiter);
24545
- resolve2(null);
24872
+ resolve3({ push: null, released: false });
24546
24873
  }, minutes * 6e4);
24547
24874
  });
24548
- return push ? pushBlock(push) : `No message in ${minutes} minutes.`;
24875
+ if (outcome.push) return pushBlock(outcome.push);
24876
+ if (outcome.released) return "Your person made you not visible to teammates: nothing can reach you now. You may stop.";
24877
+ return `No message in ${minutes} minutes. Wait again.`;
24549
24878
  }
24550
24879
  },
24551
24880
  {
@@ -24575,8 +24904,8 @@ function tools() {
24575
24904
  inputSchema: schema({ subject: str("Subject"), body: str("First message (Markdown)"), task: str("Task key, to make it about that task"), project: PROJECT }, ["subject", "body"]),
24576
24905
  run: async (args) => {
24577
24906
  const target = await project(args);
24578
- const result = await api.post(`/projects/${target.id}/threads`, { subject: String(args.subject), body: String(args.body), task: args.task });
24579
- return `Started ${result.thread.id}${result.thread.taskKey ? ` on ${result.thread.taskKey}` : " (general)"}${result.delivered ? `; reached ${result.delivered} live session(s)` : ""}.`;
24907
+ const result = await api.post(`/projects/${target.id}/threads`, { subject: String(args.subject), body: String(args.body), task: args.task, to: args.to });
24908
+ return `Started ${result.thread.id}${result.thread.to ? ` to ${result.thread.to.agent}` : ""}${result.thread.taskKey ? ` on ${result.thread.taskKey}` : result.thread.to ? "" : " (general)"}${result.delivered ? `; reached ${result.delivered} live session(s)` : ""}.`;
24580
24909
  }
24581
24910
  },
24582
24911
  {
@@ -24596,7 +24925,15 @@ function tools() {
24596
24925
  name: "thread",
24597
24926
  description: "Read a thread in full (marks it read).",
24598
24927
  inputSchema: schema({ id: str("Thread id"), project: PROJECT }, ["id"]),
24599
- run: async (args) => threadBlock(await api.get(`/projects/${(await project(args)).id}/threads/${String(args.id)}`))
24928
+ run: async (args) => {
24929
+ const query = new URLSearchParams({ limit: String(Math.max(1, Number(args.limit ?? 40))) });
24930
+ if (args.before) query.set("before", String(args.before));
24931
+ const detail = await api.get(`/projects/${(await project(args)).id}/threads/${String(args.id)}?${query}`);
24932
+ const earlier = detail.earlier > 0 && detail.messages[0] ? `\u2026 ${detail.earlier} earlier (before: ${detail.messages[0].createdAt})
24933
+
24934
+ ` : "";
24935
+ return earlier + threadBlock(detail);
24936
+ }
24600
24937
  },
24601
24938
  {
24602
24939
  name: "sessions",
@@ -24604,7 +24941,8 @@ function tools() {
24604
24941
  inputSchema: schema({ project: PROJECT }),
24605
24942
  run: async (args) => {
24606
24943
  const list = await api.get(`/projects/${(await project(args)).id}/sessions`);
24607
- return list.length ? list.map((session) => `${session.taskKey ?? "(no task)"} ${session.user.name} ${session.machine.name} since ${session.startedAt}`).join("\n") : "Nobody is live.";
24944
+ const mine = held?.session?.id ?? null;
24945
+ return list.length ? list.map((session) => sessionLine(session, mine)).join("\n") : "Nobody is live.";
24608
24946
  }
24609
24947
  },
24610
24948
  {
@@ -24672,6 +25010,173 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
24672
25010
  return `Item ${String(args.item)} done.`;
24673
25011
  }
24674
25012
  },
25013
+ /* ---------- the board itself: everything a person can do in a project ---------- */
25014
+ {
25015
+ name: "list_tasks",
25016
+ description: "Tasks on the board (done ones hidden unless `all`). Key, status, severity, assignee, title.",
25017
+ inputSchema: schema({ status: str("Only this column (key or label)"), mine: { type: "boolean", description: "Only tasks assigned to you" }, all: { type: "boolean", description: "Include done tasks" }, archived: { type: "boolean", description: "Archived tasks instead" }, project: PROJECT }),
25018
+ run: async (args) => {
25019
+ const target = await loadProject(api, (await project(args)).id);
25020
+ const list = await api.get(`/projects/${target.id}/tasks${args.archived ? "?archived=1" : ""}`);
25021
+ const me = args.mine ? (await api.get("/me")).user.id : null;
25022
+ const doneKeys = new Set(target.statuses.filter((status) => status.category === "done").map((status) => status.key));
25023
+ const only = args.status ? resolveStatus(target, String(args.status)) : null;
25024
+ const rows = list.filter((task) => (args.all || args.archived || !doneKeys.has(task.status)) && (!me || task.assignee?.userId === me) && (!only || task.status === only));
25025
+ const offset = Math.max(0, Number(args.offset ?? 0));
25026
+ const limit = Math.max(1, Number(args.limit ?? 50));
25027
+ const page = rows.slice(offset, offset + limit);
25028
+ const more = rows.length - offset - page.length;
25029
+ if (rows.length === 0) return "No tasks.";
25030
+ return [
25031
+ ...page.map((task) => `${task.key} ${target.statuses.find((status) => status.key === task.status)?.label ?? task.status} ${task.severity} ${task.assignee?.name ?? "-"} ${task.title}${task.progress.total ? ` (${task.progress.done}/${task.progress.total})` : ""}`),
25032
+ ...more > 0 ? [`\u2026 ${more} more (offset: ${offset + page.length})`] : []
25033
+ ].join("\n");
25034
+ }
25035
+ },
25036
+ {
25037
+ name: "create_task",
25038
+ description: "Create a task. The description is Markdown; write a real one: context, file paths, what done means.",
25039
+ inputSchema: schema({ title: str("Short, imperative"), description: str("Markdown"), severity: str(`One of ${TASK_SEVERITIES.join(", ")} (default medium)`), status: str("A column key or label (default: the first todo column)"), assignee: str("An email, a name, or me"), project: PROJECT }, ["title"]),
25040
+ run: async (args) => {
25041
+ const target = await loadProject(api, (await project(args)).id);
25042
+ const task = await api.post(`/projects/${target.id}/tasks`, {
25043
+ title: String(args.title),
25044
+ description: args.description ?? "",
25045
+ severity: args.severity ?? "medium",
25046
+ status: resolveStatus(target, args.column ?? args.status),
25047
+ assigneeId: await resolveAssignee(api, target.id, args.assignee) ?? null
25048
+ });
25049
+ return `Created ${task.key} ${task.title}`;
25050
+ }
25051
+ },
25052
+ {
25053
+ name: "update_task",
25054
+ description: "Change a task's title, description, notes, severity, column or assignee. Only the fields you give change.",
25055
+ inputSchema: schema({ task: str("Task key"), title: str(""), description: str("Markdown, replaced whole"), notes: str("The scratchpad, replaced whole"), severity: str(TASK_SEVERITIES.join(" | ")), status: str("A column key or label"), assignee: str("An email, a name, me, or none"), project: PROJECT }, ["task"]),
25056
+ run: async (args) => {
25057
+ const target = await loadProject(api, (await project(args)).id);
25058
+ const patch = {
25059
+ title: args.title,
25060
+ description: args.description,
25061
+ notes: args.notes,
25062
+ severity: args.severity,
25063
+ status: resolveStatus(target, args.column ?? args.status),
25064
+ assigneeId: await resolveAssignee(api, target.id, args.assignee)
25065
+ };
25066
+ if (Object.values(patch).every((value) => value === void 0)) throw new Error("Nothing to change");
25067
+ const task = await api.patch(taskPath(target.id, String(args.task)), patch);
25068
+ return `Updated ${task.key} ${task.title} [${task.status}, ${task.severity}, ${task.assignee?.name ?? "unassigned"}]`;
25069
+ }
25070
+ },
25071
+ {
25072
+ name: "move_task",
25073
+ description: "Move a task to another column, by key or label.",
25074
+ inputSchema: schema({ task: str("Task key"), column: str("Column key or label"), project: PROJECT }, ["task", "column"]),
25075
+ run: async (args) => {
25076
+ const target = await loadProject(api, (await project(args)).id);
25077
+ const task = await api.patch(taskPath(target.id, String(args.task)), { status: resolveStatus(target, String(args.column ?? args.status)) });
25078
+ return `${task.key} is in ${target.statuses.find((status) => status.key === task.status)?.label ?? task.status}.`;
25079
+ }
25080
+ },
25081
+ {
25082
+ name: "link_task",
25083
+ description: "Attach a URL to a task: a pull request, a design, a log. GitHub PRs are recognised.",
25084
+ inputSchema: schema({ task: str("Task key"), url: str("The URL"), title: str("What to call it (default: from the URL)"), project: PROJECT }, ["task", "url"]),
25085
+ run: async (args) => {
25086
+ const target = await project(args);
25087
+ const task = await api.post(`${taskPath(target.id, String(args.task))}/links`, { url: String(args.url), title: args.title });
25088
+ return `Linked. ${task.key} now has ${task.links.length} link(s).`;
25089
+ }
25090
+ },
25091
+ {
25092
+ name: "archive_task",
25093
+ description: "Take a task off the board (its history is kept). `restore` puts it back.",
25094
+ inputSchema: schema({ task: str("Task key"), restore: { type: "boolean", description: "Put an archived task back" }, project: PROJECT }, ["task"]),
25095
+ run: async (args) => {
25096
+ const target = await project(args);
25097
+ if (args.action === "restore") args = { ...args, restore: true };
25098
+ const task = await api.post(`${taskPath(target.id, String(args.task))}/${args.restore ? "unarchive" : "archive"}`);
25099
+ return args.restore ? `${task.key} is back on the board.` : `${task.key} is archived.`;
25100
+ }
25101
+ },
25102
+ {
25103
+ name: "task_history",
25104
+ description: "Everything that happened to a task, oldest first.",
25105
+ inputSchema: schema({ task: str("Task key"), project: PROJECT }, ["task"]),
25106
+ run: async (args) => {
25107
+ const target = await project(args);
25108
+ const query = new URLSearchParams({ limit: String(Math.max(1, Number(args.limit ?? 50))) });
25109
+ if (args.before) query.set("before", String(args.before));
25110
+ const events = await api.get(`${taskPath(target.id, String(args.task))}/events?${query}`);
25111
+ if (events.length === 0) return "No history yet.";
25112
+ const lines = events.map((record2) => `${record2.createdAt.slice(0, 16).replace("T", " ")} ${record2.actor.name} ${JSON.stringify(record2.event)}`);
25113
+ const full = events.length >= Number(query.get("limit"));
25114
+ return [...full ? [`\u2026 earlier history: pass before: ${events[0].createdAt}`] : [], ...lines].join("\n");
25115
+ }
25116
+ },
25117
+ {
25118
+ name: "plan_task",
25119
+ description: "Append plan items to a task, in order. Use this to break long work into checkable steps; keep each item's note current as you go.",
25120
+ inputSchema: schema({ task: str("Task key"), items: { type: "array", items: { type: "string" }, description: "Item titles, in order" }, project: PROJECT }, ["task", "items"]),
25121
+ run: async (args) => {
25122
+ const target = await project(args);
25123
+ const items = await api.post(`${taskPath(target.id, String(args.task))}/items`, { items: args.items.map((title3) => ({ title: title3 })) });
25124
+ return items.map((item, index) => `${index + 1}. [${item.done ? "x" : " "}] ${item.title}`).join("\n");
25125
+ }
25126
+ },
25127
+ {
25128
+ name: "edit_item",
25129
+ description: "Retitle, reopen or remove a plan item (numbered as in show_task).",
25130
+ inputSchema: schema({ task: str("Task key"), item: int2("Item number"), title: str("New title"), reopen: { type: "boolean", description: "Untick it" }, remove: { type: "boolean", description: "Remove it" }, project: PROJECT }, ["task", "item"]),
25131
+ run: async (args) => {
25132
+ const target = await project(args);
25133
+ const items = await api.get(`${taskPath(target.id, String(args.task))}/items`);
25134
+ const item = items[Number(args.item) - 1];
25135
+ if (!item) throw new Error(`No item ${String(args.item)}`);
25136
+ if (args.action === "remove") args = { ...args, remove: true };
25137
+ if (args.action === "reopen") args = { ...args, reopen: true };
25138
+ if (args.remove) {
25139
+ await api.delete(`${taskPath(target.id, String(args.task))}/items/${item.id}`);
25140
+ return `Item ${String(args.item)} removed.`;
25141
+ }
25142
+ await api.patch(`${taskPath(target.id, String(args.task))}/items/${item.id}`, { title: args.title, done: args.reopen ? false : void 0 });
25143
+ return `Item ${String(args.item)} updated.`;
25144
+ }
25145
+ },
25146
+ {
25147
+ name: "columns",
25148
+ description: "The board's columns in order: key, label, and what each counts as (backlog, todo, in_progress, done).",
25149
+ inputSchema: schema({ project: PROJECT }),
25150
+ run: async (args) => {
25151
+ const target = await loadProject(api, (await project(args)).id);
25152
+ return target.statuses.map((status) => `${status.key} ${status.label} (${status.category})`).join("\n");
25153
+ }
25154
+ },
25155
+ {
25156
+ name: "members",
25157
+ description: "Who can be assigned: name, email, role.",
25158
+ inputSchema: schema({ project: PROJECT }),
25159
+ run: async (args) => {
25160
+ const target = await loadProject(api, (await project(args)).id);
25161
+ const { members: members2 } = await api.get(`/orgs/${target.orgId}/members`);
25162
+ return members2.map((member) => `${member.name} ${member.email} ${member.role}`).join("\n");
25163
+ }
25164
+ },
25165
+ {
25166
+ name: "projects",
25167
+ description: "Every project you can reach, and which one this repository is linked to.",
25168
+ inputSchema: schema({}),
25169
+ run: async () => {
25170
+ const list = await api.get("/projects");
25171
+ let linked = null;
25172
+ try {
25173
+ linked = (await resolveOnly({})).key;
25174
+ } catch {
25175
+ linked = null;
25176
+ }
25177
+ return list.map((candidate) => `${candidate.key === linked ? "* " : " "}${candidate.key} ${candidate.name} (${candidate.openTaskCount} open)`).join("\n") + (linked ? "" : "\n\nThis repository is not linked: slop link <KEY>");
25178
+ }
25179
+ },
24675
25180
  {
24676
25181
  name: "finish_task",
24677
25182
  description: "Move a task to the first done column.",
@@ -24687,7 +25192,7 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
24687
25192
  }
24688
25193
  function readJson(path3) {
24689
25194
  try {
24690
- return JSON.parse(readFileSync9(path3, "utf8"));
25195
+ return JSON.parse(readFileSync11(path3, "utf8"));
24691
25196
  } catch {
24692
25197
  return {};
24693
25198
  }
@@ -24700,66 +25205,135 @@ function installClaude() {
24700
25205
  } catch {
24701
25206
  done.push("Claude Code: run `claude mcp add --scope user slop -- slop mcp` (the claude command was not found here)");
24702
25207
  }
24703
- const dir = join6(homedir3(), ".claude");
24704
- const path3 = join6(dir, "settings.json");
24705
- mkdirSync5(dir, { recursive: true });
25208
+ const dir = join8(homedir3(), ".claude");
25209
+ const path3 = join8(dir, "settings.json");
25210
+ mkdirSync7(dir, { recursive: true });
24706
25211
  const settings = readJson(path3);
24707
- settings.hooks = mergeHooks(settings.hooks ?? {});
24708
- settings.env = { ...settings.env ?? {}, MCP_TOOL_TIMEOUT: "1800000" };
24709
- writeFileSync6(path3, `${JSON.stringify(settings, null, 2)}
25212
+ settings.hooks = mergeHooks(settings.hooks ?? {}, "claude");
25213
+ settings.env = { ...settings.env ?? {}, MCP_TOOL_TIMEOUT: "21600000" };
25214
+ writeFileSync7(path3, `${JSON.stringify(settings, null, 2)}
24710
25215
  `);
24711
- done.push(`Claude Code: PostToolUse and Stop hooks and MCP_TOOL_TIMEOUT written to ${path3}`);
25216
+ done.push(`Claude Code: SessionStart, PostToolUse and Stop hooks and MCP_TOOL_TIMEOUT written to ${path3}`);
24712
25217
  return done;
24713
25218
  }
24714
- function mergeHooks(hooks) {
24715
- const stop = hooks.Stop ?? [];
24716
- if (!stop.some((entry) => entry.hooks?.some((hook) => hook.command === "slop hook stop"))) {
24717
- stop.push({ hooks: [{ type: "command", command: "slop hook stop", timeout: 30, statusMessage: "Checking Slopware Tasks for messages" }] });
24718
- }
24719
- const tick = hooks.PostToolUse ?? [];
24720
- if (!tick.some((entry) => entry.hooks?.some((hook) => hook.command === "slop hook tick"))) {
24721
- tick.push({ hooks: [{ type: "command", command: "slop hook tick", timeout: 5, statusMessage: "Slopware Tasks inbox" }] });
24722
- }
24723
- return { ...hooks, Stop: stop, PostToolUse: tick };
25219
+ function mergeHooks(hooks, harness) {
25220
+ const ensure = (event, verb, timeout, statusMessage) => {
25221
+ const command = `slop hook ${verb} --harness ${harness}`;
25222
+ const entries = hooks[event] ?? [];
25223
+ const ours = entries.flatMap((entry) => entry.hooks ?? []).find((hook) => hook.command === `slop hook ${verb}` || hook.command?.startsWith(`slop hook ${verb} `));
25224
+ if (ours) Object.assign(ours, { command, timeout, statusMessage });
25225
+ else entries.push({ hooks: [{ type: "command", command, timeout, statusMessage }] });
25226
+ hooks[event] = entries;
25227
+ };
25228
+ ensure("SessionStart", "start", 5, "Slopware Tasks");
25229
+ ensure("PostToolUse", "tick", 5, "Slopware Tasks inbox");
25230
+ ensure("Stop", "stop", 30, "Checking Slopware Tasks for messages");
25231
+ return hooks;
24724
25232
  }
24725
25233
  function installCodex() {
24726
25234
  const done = [];
24727
- const dir = join6(homedir3(), ".codex");
24728
- mkdirSync5(dir, { recursive: true });
24729
- const configPath2 = join6(dir, "config.toml");
24730
- const current = existsSync6(configPath2) ? readFileSync9(configPath2, "utf8") : "";
24731
- if (/^\[mcp_servers\.slop\]/m.test(current)) done.push(`Codex: MCP server already in ${configPath2}`);
24732
- else {
24733
- writeFileSync6(configPath2, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
25235
+ const dir = join8(homedir3(), ".codex");
25236
+ mkdirSync7(dir, { recursive: true });
25237
+ const configPath3 = join8(dir, "config.toml");
25238
+ const current = existsSync8(configPath3) ? readFileSync11(configPath3, "utf8") : "";
25239
+ if (/^\[mcp_servers\.slop\]/m.test(current)) {
25240
+ if (/tool_timeout_sec = 1800\b/.test(current)) writeFileSync7(configPath3, current.replace(/tool_timeout_sec = 1800\b/, "tool_timeout_sec = 21600"));
25241
+ done.push(`Codex: MCP server already in ${configPath3}`);
25242
+ } else {
25243
+ writeFileSync7(configPath3, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
24734
25244
  command = "slop"
24735
25245
  args = ["mcp"]
24736
- tool_timeout_sec = 1800
25246
+ tool_timeout_sec = 21600
24737
25247
  `);
24738
- done.push(`Codex: MCP server "slop" added to ${configPath2}`);
25248
+ done.push(`Codex: MCP server "slop" added to ${configPath3}`);
24739
25249
  }
24740
- const hooksPath = join6(dir, "hooks.json");
25250
+ const hooksPath = join8(dir, "hooks.json");
24741
25251
  const file2 = readJson(hooksPath);
24742
- file2.hooks = mergeHooks(file2.hooks ?? {});
24743
- writeFileSync6(hooksPath, `${JSON.stringify(file2, null, 2)}
25252
+ file2.hooks = mergeHooks(file2.hooks ?? {}, "codex");
25253
+ writeFileSync7(hooksPath, `${JSON.stringify(file2, null, 2)}
24744
25254
  `);
24745
- done.push(`Codex: PostToolUse and Stop hooks written to ${hooksPath}. Codex asks you to trust new hooks once (/hooks in a session).`);
25255
+ done.push(`Codex: SessionStart, PostToolUse and Stop hooks written to ${hooksPath}`);
24746
25256
  return done;
24747
25257
  }
24748
- async function hookCwd() {
24749
- const input2 = await new Promise((resolve2) => {
25258
+ async function trustCodexHooks() {
25259
+ const server = spawn2("codex", ["app-server"], { stdio: ["pipe", "pipe", "ignore"] });
25260
+ let buffer = "";
25261
+ const pending = /* @__PURE__ */ new Map();
25262
+ server.stdout.on("data", (chunk) => {
25263
+ buffer += String(chunk);
25264
+ let index;
25265
+ while ((index = buffer.indexOf("\n")) >= 0) {
25266
+ const line = buffer.slice(0, index);
25267
+ buffer = buffer.slice(index + 1);
25268
+ if (!line.trim()) continue;
25269
+ try {
25270
+ const message = JSON.parse(line);
25271
+ if (typeof message.id === "number") pending.get(message.id)?.(message);
25272
+ } catch {
25273
+ }
25274
+ }
25275
+ });
25276
+ let nextId = 1;
25277
+ const call = (method, params) => new Promise((resolve3, reject) => {
25278
+ const id = nextId++;
25279
+ const timer = setTimeout(() => {
25280
+ pending.delete(id);
25281
+ reject(new Error(`${method} timed out`));
25282
+ }, 15e3);
25283
+ pending.set(id, (message) => {
25284
+ clearTimeout(timer);
25285
+ if (message.error) reject(new Error(message.error.message ?? `${method} failed`));
25286
+ else resolve3(message.result);
25287
+ });
25288
+ server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
25289
+ `);
25290
+ });
25291
+ const notify = (method, params) => server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}
25292
+ `);
25293
+ try {
25294
+ await call("initialize", { clientInfo: { name: "slop", version: "0.4.0" }, capabilities: { experimentalApi: true } });
25295
+ notify("initialized", {});
25296
+ const listed = await call("hooks/list", { cwds: [process.cwd()] });
25297
+ const ours = listed.data.flatMap((entry) => entry.hooks).filter((hook) => hook.command?.startsWith("slop hook "));
25298
+ if (ours.length === 0) return "Codex: could not find the slop hooks in its list; trust them once with /hooks in a Codex session";
25299
+ const untrusted = ours.filter((hook) => hook.trustStatus !== "trusted");
25300
+ if (untrusted.length === 0) return "Codex: the slop hooks are already trusted";
25301
+ const value = Object.fromEntries(untrusted.map((hook) => [hook.key, { trusted_hash: hook.currentHash }]));
25302
+ await call("config/batchWrite", { edits: [{ keyPath: "hooks.state", value, mergeStrategy: "upsert" }], reloadUserConfig: true });
25303
+ return `Codex: trusted ${untrusted.length} slop hook(s), so it will not ask`;
25304
+ } catch (error62) {
25305
+ return `Codex: could not trust the hooks automatically (${error62 instanceof Error ? error62.message : String(error62)}); trust them once with /hooks in a Codex session`;
25306
+ } finally {
25307
+ server.kill();
25308
+ }
25309
+ }
25310
+ async function hookInput(harness) {
25311
+ const input2 = await new Promise((resolve3) => {
24750
25312
  let data = "";
24751
25313
  process.stdin.on("data", (chunk) => data += chunk);
24752
- process.stdin.on("end", () => resolve2(data));
24753
- setTimeout(() => resolve2(data), 2e3);
25314
+ process.stdin.on("end", () => resolve3(data));
25315
+ setTimeout(() => resolve3(data), 2e3);
24754
25316
  });
25317
+ let cwd = process.cwd();
25318
+ let conversation = null;
24755
25319
  try {
24756
- return JSON.parse(input2).cwd ?? process.cwd();
25320
+ const parsed = JSON.parse(input2);
25321
+ cwd = parsed.cwd ?? cwd;
25322
+ conversation = parsed.session_id ?? null;
24757
25323
  } catch {
24758
- return process.cwd();
24759
25324
  }
25325
+ if (conversation) rememberConversation(cwd, { id: conversation, harness: harness ?? "other" });
25326
+ return { cwd, conversation };
24760
25327
  }
24761
- async function tickHook() {
24762
- const arrived = inboxDrain(await hookCwd());
25328
+ async function startHook(harness) {
25329
+ const { conversation } = await hookInput(harness);
25330
+ if (!conversation) return;
25331
+ const text = `On Slopware Tasks you are ${agentNameFor(conversation)}: that is how people and other agents address you (session who lists everyone).`;
25332
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text } })}
25333
+ `);
25334
+ }
25335
+ async function tickHook(harness) {
25336
+ const arrived = inboxDrain((await hookInput(harness)).cwd);
24763
25337
  if (arrived.length === 0) return;
24764
25338
  const text = `Messages arrived on Slopware Tasks while you worked:
24765
25339
 
@@ -24769,8 +25343,8 @@ Act or answer with the slop MCP \`reply\` tool, then carry on.`;
24769
25343
  process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } })}
24770
25344
  `);
24771
25345
  }
24772
- async function stopHook() {
24773
- const cwd = await hookCwd();
25346
+ async function stopHook(harness) {
25347
+ const { cwd } = await hookInput(harness);
24774
25348
  process.chdir(cwd);
24775
25349
  const parts = inboxDrain(cwd).map(pushBlock);
24776
25350
  const repo = findRepo();
@@ -24792,43 +25366,117 @@ ${parts.join("\n\n")}` })}
24792
25366
  }
24793
25367
  const general = await api.get(`/projects/${project.id}/threads?scope=general&unread=1&read=1`);
24794
25368
  if (general.length) parts.push("Unread general threads (context):", ...general.map(threadLine));
25369
+ const sessionId = rememberedSession(cwd);
25370
+ const own2 = sessionId ? (await api.get(`/projects/${project.id}/sessions`)).find((session) => session.id === sessionId) : void 0;
25371
+ if (own2?.reachable) {
25372
+ const unread = parts.length ? `First, these arrived while you worked:
25373
+
25374
+ ${parts.join("\n")}
25375
+
25376
+ ` : "";
25377
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: `${unread}You are reachable on Slopware Tasks, so stay listening: call the slop session tool with action wait (no other arguments). When it returns a message, act or answer it (say reply), then stop again; if it returns with nothing, call wait again. It tells you if you may stop.` })}
25378
+ `);
25379
+ return;
25380
+ }
24795
25381
  if (parts.length === 0) return;
24796
- process.stdout.write(`${JSON.stringify({ decision: "block", reason: `Messages arrived on Slopware Tasks while you worked. Read them (slop thread show <id>), act or reply (slop thread reply <id> "\u2026"), then continue or stop.
25382
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: `Messages arrived on Slopware Tasks while you worked. Read them (threads read), act or reply (say reply), then continue or stop.
24797
25383
 
24798
25384
  ${parts.join("\n")}` })}
24799
25385
  `);
24800
25386
  }
24801
- function withInbox(list) {
24802
- return list.map(
24803
- (tool) => tool.name === "check_messages" || tool.name === "wait_for_message" ? tool : {
24804
- ...tool,
24805
- run: async (args) => {
24806
- const result = await tool.run(args);
25387
+ function tools() {
25388
+ const fine = handlers();
25389
+ const run2 = (name, args) => {
25390
+ const handler = fine.find((candidate) => candidate.name === name);
25391
+ if (!handler) throw new Error(`No such action`);
25392
+ return handler.run(args);
25393
+ };
25394
+ const group = (name, description2, actions, properties) => ({
25395
+ name,
25396
+ description: description2,
25397
+ inputSchema: schema({ action: { type: "string", enum: Object.keys(actions) }, ...properties, project: PROJECT }, ["action"]),
25398
+ run: (args) => {
25399
+ const target = actions[String(args.action)];
25400
+ if (!target) throw new Error(`action must be one of: ${Object.keys(actions).join(", ")}`);
25401
+ return run2(target, args);
25402
+ }
25403
+ });
25404
+ return [
25405
+ group(
25406
+ "board",
25407
+ "The project: tasks on the board, columns, members, projects.",
25408
+ { list: "list_tasks", columns: "columns", members: "members", projects: "projects" },
25409
+ { status: str("list: only this column"), mine: { type: "boolean" }, all: { type: "boolean", description: "list: include done" }, archived: { type: "boolean" }, limit: int2("list: rows (default 50)"), offset: int2("list: skip") }
25410
+ ),
25411
+ group(
25412
+ "task",
25413
+ "One task. show; create; update (only given fields change); move; link; archive/restore; history; finish (first done column, releases your session).",
25414
+ { show: "show_task", create: "create_task", update: "update_task", move: "move_task", link: "link_task", archive: "archive_task", restore: "archive_task", history: "task_history", finish: "finish_task" },
25415
+ { task: str("Key, e.g. WEB-12 (all but create)"), title: str(""), description: str("Markdown"), notes: str("scratchpad, Markdown"), severity: str("low|medium|high|critical"), column: str("column key or label"), assignee: str("email, name, me, or none"), url: str("link: the URL"), limit: int2("history: newest N (default 50)"), before: str("history: page back from this time") }
25416
+ ),
25417
+ group(
25418
+ "plan",
25419
+ "The task's plan: numbered items, each with a note that is its current state. add items; note/done/reopen/rename/remove one by number.",
25420
+ { add: "plan_task", note: "note_item", done: "finish_item", reopen: "edit_item", rename: "edit_item", remove: "edit_item" },
25421
+ { task: str("Task key"), items: { type: "array", items: { type: "string" }, description: "add: titles in order" }, item: int2("item number"), note: str("note: what is true now"), title: str("rename: new title") }
25422
+ ),
25423
+ group(
25424
+ "session",
25425
+ "Being live. claim: take a task, go live on it, get what is unread (messages then reach you after any tool call). release; finish is on task. who: live agents by name (you included). check: unread now. wait: block for the next message (the Stop hook sends you here after your turn, so you stay reachable).",
25426
+ { claim: "claim_task", release: "release_task", who: "sessions", check: "check_messages", wait: "wait_for_message" },
25427
+ { task: str("Task key"), minutes: int2("wait: give up after (default 300)") }
25428
+ ),
25429
+ group("threads", "Talk between agents and people. list threads; read one (marks it read; newest 40 unless limit/before).", { list: "threads", read: "thread" }, { task: str("list: about this task"), unread: { type: "boolean" }, id: str("read: thread id"), limit: int2("read: newest N"), before: str("read: page back from this time") }),
25430
+ group(
25431
+ "say",
25432
+ "post: start a thread (with task: reaches whoever is live on it; with to: one live agent by name; without either: general, for people). reply: in a thread; reaches its task's live sessions, its participants and its addressee.",
25433
+ { post: "post", reply: "reply" },
25434
+ { subject: str("post"), body: str("Markdown"), task: str("post: about this task"), to: str("post: an agent's name (session who)"), thread: str("reply: thread id") }
25435
+ )
25436
+ ];
25437
+ }
25438
+ function withInbox(list, version2) {
25439
+ return list.map((tool) => ({
25440
+ ...tool,
25441
+ run: async (args) => {
25442
+ const result = bounded(await tool.run(args));
25443
+ const parts = [result];
25444
+ if (!(tool.name === "session" && (args.action === "check" || args.action === "wait"))) {
24807
25445
  const arrived = inboxDrain(process.cwd());
24808
- return arrived.length ? `${result}
24809
-
24810
- --- Messages arrived while you worked ---
24811
- ${arrived.map(pushBlock).join("\n\n")}` : result;
25446
+ if (arrived.length) parts.push(`--- Messages arrived while you worked ---
25447
+ ${arrived.map(pushBlock).join("\n\n")}`);
24812
25448
  }
25449
+ const notice = updateChecksEnabled() ? updateNotice(version2) : null;
25450
+ if (notice) parts.push(notice);
25451
+ return parts.join("\n\n");
24813
25452
  }
24814
- );
25453
+ }));
24815
25454
  }
24816
25455
  function registerMcpCommands(program3, version2) {
24817
25456
  const mcp = program3.command("mcp").description("The MCP server: the board and its threads as tools for Claude Code, Codex and any MCP client. Holds your live session while it runs.");
24818
- mcp.command("serve", { isDefault: true }).description("Serve MCP over stdio (what the harness runs).").action(() => serveMcp(withInbox(tools()), { name: "slop", version: version2 }));
24819
- mcp.command("install").description("Register the server and the two hooks with Claude Code and Codex on this machine.").action(() => {
25457
+ mcp.command("serve", { isDefault: true }).description("Serve MCP over stdio (what the harness runs).").action(() => serveMcp(withInbox(tools(), version2), { name: "slop", version: version2 }));
25458
+ mcp.command("install").description("Register the server and the two hooks with Claude Code and Codex on this machine.").action(async () => {
24820
25459
  const lines = [...installClaude(), ...installCodex()];
25460
+ let codexPresent = true;
25461
+ try {
25462
+ execFileSync3("codex", ["--version"], { stdio: "ignore" });
25463
+ } catch {
25464
+ codexPresent = false;
25465
+ }
25466
+ if (codexPresent) lines.push(await trustCodexHooks());
24821
25467
  emit({ done: lines }, () => lines.join("\n"));
24822
25468
  });
24823
25469
  const hook = program3.command("hook").description("Harness hooks (installed by slop mcp install).");
24824
- hook.command("stop").description("Stop hook (Claude Code and Codex): hands over unread messages instead of letting the agent stop.").action(stopHook);
24825
- hook.command("tick").description("PostToolUse hook (Claude Code and Codex): hands over messages the held session received, as context, right after any tool call.").action(tickHook);
25470
+ const harnessOf = (options) => options.harness === "claude" || options.harness === "codex" ? options.harness : options.harness ? "other" : null;
25471
+ hook.command("start").description("SessionStart hook (Claude Code and Codex): writes the conversation down so the MCP server is its session, and tells the agent its name.").option("--harness <name>", "claude or codex").action((options) => startHook(harnessOf(options)));
25472
+ hook.command("stop").description("Stop hook (Claude Code and Codex): hands over unread messages instead of letting the agent stop.").option("--harness <name>", "claude or codex").action((options) => stopHook(harnessOf(options)));
25473
+ hook.command("tick").description("PostToolUse hook (Claude Code and Codex): hands over messages the held session received, as context, right after any tool call.").option("--harness <name>", "claude or codex").action((options) => tickHook(harnessOf(options)));
24826
25474
  }
24827
25475
 
24828
25476
  // src/commands/sessions.ts
24829
- import { readFileSync as readFileSync10 } from "fs";
25477
+ import { readFileSync as readFileSync12 } from "fs";
24830
25478
  var taskPath2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24831
- var sessionLine = (session) => [session.taskKey ?? "(no task)", session.user.name, session.machine.name, session.startedAt];
25479
+ var sessionLine2 = (session) => [session.agent?.name ?? "agent", session.taskKey ?? "(no task)", session.user.name, session.machine.name, session.where?.directory ? `${session.where.directory}${session.where.branch ? `@${session.where.branch}` : ""}` : "-", session.startedAt, session.state === "reconnecting" ? "reconnecting" : "live"];
24832
25480
  function registerSessionCommands(program3, task) {
24833
25481
  const withProject = (command) => command.option("-p, --project <key>", "project key (default: the project this repository is linked to)");
24834
25482
  const session = program3.command("session").description("Claim a task and be reachable on it while you work.");
@@ -24859,7 +25507,8 @@ function registerSessionCommands(program3, task) {
24859
25507
  `);
24860
25508
  const outcome = await waitForMessage(api, project.id, key, {
24861
25509
  timeoutMs: options.timeout ? Number(options.timeout) * 1e3 : 0,
24862
- onHello: (session2) => note2(isJsonMode() ? JSON.stringify({ live: session2 }) : `live on ${session2.taskKey} as ${session2.user.name} (${session2.machine.name}); waiting\u2026`),
25510
+ claim: agentIdentity(process.cwd(), "directory"),
25511
+ onHello: (session2) => note2(isJsonMode() ? JSON.stringify({ live: session2 }) : `live on ${session2.taskKey} as ${session2.agent?.name ?? "agent"} for ${session2.user.name} (${session2.machine.name}); waiting\u2026`),
24863
25512
  onMessage: options.follow ? (push) => {
24864
25513
  emit(push, () => `${pushBlock(push)}
24865
25514
  `);
@@ -24884,7 +25533,7 @@ function registerSessionCommands(program3, task) {
24884
25533
  const api = authedApi();
24885
25534
  const project = await resolveProject(api, options.project);
24886
25535
  const sessions = await api.get(`/projects/${project.id}/sessions`);
24887
- emit(sessions, () => sessions.length === 0 ? "Nobody is live." : table([["TASK", "WHO", "MACHINE", "SINCE"], ...sessions.map(sessionLine)]));
25536
+ emit(sessions, () => sessions.length === 0 ? "Nobody is live." : table([["AGENT", "TASK", "WHO", "MACHINE", "WHERE", "SINCE", "STATE"], ...sessions.map(sessionLine2)]));
24888
25537
  });
24889
25538
  }
24890
25539
 
@@ -24910,13 +25559,13 @@ ${written.map((path3) => ` ${path3}`).join("\n")}`);
24910
25559
  }
24911
25560
 
24912
25561
  // src/index.ts
24913
- 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.2.4").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => setJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
25562
+ 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.4.0").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => setJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
24914
25563
  registerAuthCommands(program2);
24915
25564
  registerProjectCommands(program2);
24916
25565
  var taskCommand = registerTaskCommands(program2);
24917
25566
  registerSessionCommands(program2, taskCommand);
24918
25567
  registerThreadCommands(program2, taskCommand);
24919
- registerMcpCommands(program2, "0.2.4");
25568
+ registerMcpCommands(program2, "0.4.0");
24920
25569
  registerGuideCommand(program2);
24921
25570
  registerSkillCommands(program2);
24922
25571
  program2.parseAsync().catch(fail);