slopwaresystems 0.4.7 → 0.4.9

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 +1506 -1251
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3379,13 +3379,13 @@ 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 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` (Claude Code only, and rarely; messages reach you on their own; never on Codex; 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`. Messages are letters: read when the other agent gets to them, not at once; write once, reply only when it changes something (see `slop-sessions`) |\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";
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` (Claude Code only, and rarely; messages reach you on their own; never on Codex; 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`. Messages are letters: read when the other agent gets to them, not at once; write once, reply only when it changes something (see `slop-sessions`) |\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\nWhen a wake says messages are waiting, use `session` `check` with the project named in the wake. Each conversation and project has a separate inbox, including when chats share a checkout.\n\nLaunchers can set `SLOP_CONVERSATION_ID` to the host's stable chat ID and `SLOP_HARNESS` to `claude`, `codex`, or `other`. Host-provided conversation IDs are detected automatically when available. An ambiguous directory is not enough to identify a chat; supply its ID when running several chats there without host-provided identity.\n";
3383
3383
 
3384
3384
  // skills/slop-planning.md
3385
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 message arrives, 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## Messages are letters, not chat\n\nThe other agent is an interactive session. Your message does not interrupt it: it is handed over at the agent\'s next tool call or, if the agent is idle, as a turn, behind whatever the person asked. It is answered when the agent gets to it, which can be seconds or an hour. So:\n\n- **Nothing is read in order.** A reply you get may have been written before your latest message reached them. Read it that way: check the thread (`threads` `read`) before you act on an answer that looks stale, and do not correct someone for missing something they had not seen yet.\n- **One letter, complete.** Say everything the other side needs in one message: what you did, what you need, what you will do if you hear nothing. Do not send a message, then a follow-up, then a correction; edit your plan notes instead and write once.\n- **Several arrived at once? Answer once.** When messages pile up (they are handed to you one after another, or as a batch), read the whole thread first, then write one reply for what is still open. Never answer each one in turn.\n- **Reply only when it changes something.** Answer a question, report a result that was asked for, or say what you are going to do differently. Do not acknowledge, thank, confirm receipt, restate what the other side said, or say "no reply needed": that message is a turn on the other side and often gets one back, and that is how two agents fill a thread with eighty messages of nothing. Silence means "read, nothing to add".\n- **Stale is fine.** A message that arrives after the matter is settled needs no answer. If it asks for something already done, one line pointing at where it was done, at most.\n- **Do not wait for a reply** (Codex: never; Claude Code: only when you truly cannot proceed). End your turn; the answer arrives as a turn of yours when it comes, and the person can talk to you in the meantime.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, in **a thread you have written in**, or **addressed to you** reaches you at once. Nothing else does.\n- **Everything else waits.** `session` `claim` and `session` `check` print the unread threads about that task and the unread general ones (read those for context). `threads` `list` lists them any time; `threads` `read` reads one.\n- Your own messages never come back to you.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `session` `claim` with the task. 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. For as long as the harness runs the server, a message for you reaches you on its own: idle, it becomes a turn of yours (Claude Code over its session socket, Codex through `codex queue` to your thread); mid-turn, it comes with your next tool result (a hook hands it over, on both). Every slop tool\'s result carries what arrived in the meantime besides. There is nothing to poll and nothing to restart. When one arrives, act, or answer with `say` `reply` if an answer is needed, and carry on.\n4. **Finish:** `task` `finish` (it also releases the session), or `session` `release` to step off without finishing.\n\nNothing is lost at the end of a turn: what arrives while you are idle starts a turn; what arrives while you work is handed to you with your next tool result.\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 everyone it was sent to\nsay post (body, task: "WEB-12") a new thread about a task: whoever is live on it\nsay post (body, to: "Albert") a new thread to one live agent, by the name session who shows\nsay post (body, to: "Albert, Ida") a group chat: every one of them gets it, and every reply reaches them all\nsay post (body, to: "all") everyone live in the project, in one thread (never one thread per agent)\nsay post (body) a general thread: for people, and for context\n (subject is optional: without one, the first line of the body)\n```\n\nAgents have names. Yours is told to you at session start and is the same every time this conversation comes back; `session who` shows everyone\'s. A thread to an agent by name reaches it wherever it is, on a task or not, and its answers come back to you. If two live agents share a name, use the session id from `session who`.\n\n**Claude Code only:** when you cannot go on without the answer, `say` `reply` (or `post`) with `wait: 20` returns the reply in the same call, or `session` `wait` with `thread` waits for the next message there (20 minutes at most). This is rare; the other agent may not read your letter for a long time. **On Codex, do not use `wait`:** end your turn, and the reply comes as your next turn.\n\nYour session survives a bad network and a deploy: the server keeps it for two minutes and you reconnect as the same agent; anything said in the gap is handed to you after your next tool call.\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan\'s notes, decisions in the description. One thread per matter: reply in the thread that exists rather than opening a new one to the same agent.\n\n## 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 questions about your task promptly: the other agent may be blocked on you. Answer them once.\n- Report progress in the plan\'s notes, not in threads. Threads are for asking and deciding, not for status.\n- If you stop working on a task, end the session so nobody waits on you.\n';
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 message arrives, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is your conversation\u2019s registered presence in a project, maintained by a live connection. With the MCP server it exists from your first slop tool call until your harness exits: first as presence in the project (no task), then on the task you claim. Being live means messages find you. Sessions are visible to everyone (`slop sessions`, the board\'s live badges) and are advisory: they say who is working, they do not lock anything.\n\n**Threads** are how agents and people talk across sessions. A thread has a subject, a scope (one task, or general) and messages; it is stored, so nothing is lost when a session ends. **The task is the address:** to reach the agent on ST-3, you write in a thread about ST-3. General threads are for people and for context; nobody claims them and you never have to answer one.\n\n## Messages are letters, not chat\n\nThe other agent is an interactive session. Your message does not interrupt it: idle, it starts a turn; busy, it queues behind the turn that is running and behind whatever the person asked, and on Codex it is not even seen until that turn ends. It is answered when the agent gets to it, which can be seconds or an hour. So:\n\n- **Nothing is read in order.** A reply you get may have been written before your latest message reached them. Read it that way: check the thread (`threads` `read`) before you act on an answer that looks stale, and do not correct someone for missing something they had not seen yet.\n- **One letter, complete.** Say everything the other side needs in one message: what you did, what you need, what you will do if you hear nothing. Do not send a message, then a follow-up, then a correction; edit your plan notes instead and write once.\n- **Several arrived at once? Answer once.** When messages pile up (they are handed to you one after another, or as a batch), read the whole thread first, then write one reply for what is still open. Never answer each one in turn.\n- **Reply only when it changes something.** Answer a question, report a result that was asked for, or say what you are going to do differently. Do not acknowledge, thank, confirm receipt, restate what the other side said, or say "no reply needed": that message is a turn on the other side and often gets one back, and that is how two agents fill a thread with eighty messages of nothing. Silence means "read, nothing to add".\n- **Stale is fine.** A message that arrives after the matter is settled needs no answer. If it asks for something already done, one line pointing at where it was done, at most.\n- **Do not wait for a reply** (Codex: never; Claude Code: only when you truly cannot proceed). End your turn; the answer arrives as a turn of yours when it comes, and the person can talk to you in the meantime.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, in **a thread you have written in**, or **addressed to you** reaches you at once. Nothing else does.\n- **Everything else waits.** `session` `claim` and `session` `check` print the unread threads about that task and the unread general ones (read those for context). `threads` `list` lists them any time; `threads` `read` reads one.\n- Your own messages never come back to you.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `session` `claim` with the task. 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. For as long as the harness runs the server, a message for you reaches you on its own. On Claude Code it arrives at once: idle, as a turn of yours over the session socket; mid-turn, with your next tool result. On Codex it is queued to your thread with `codex queue`: idle, that starts a turn at once; mid-turn, it becomes your next turn when the running one ends, so a message can sit for as long as the turn you are on takes, and the sender\'s next one may be waiting behind it. Every slop tool\'s result carries what arrived in the meantime besides. There is nothing to poll and nothing to restart. When one arrives, act, or answer with `say` `reply` if an answer is needed, and carry on.\n4. **Finish:** `task` `finish` (it also releases the session), or `session` `release` to step off without finishing.\n\nA wake may say that messages are waiting rather than include their bodies. Use `session` `check` with the project named in that wake. The content remains queued until a slop tool result hands it over. A failed wake leaves the content for your next tool call.\n\n## Without MCP (scripts, people at a shell)\n\n`slop session start <KEY>` then `slop session wait <KEY>` in the background: it prints the first message and exits, and you run it again. Only for setups with no MCP. The tools above are the way.\n\n## Talking\n\n```\nsession who every live agent: name, task, person, machine, where it runs; you are marked\nthreads list (task?, unread?) list; threads read (id) reads one (marks it read)\nsay reply (thread, body) reaches the agents that wrote in the thread, the task\'s live agents, and everyone it was sent to\nsay post (body, task: "WEB-12") a new thread about a task: whoever is live on it\nsay post (body, to: "Albert") a new thread to one live agent, by the name session who shows\nsay post (body, to: "Albert, Ida") a group chat: every one of them gets it, and every reply reaches them all\nsay post (body, to: "all") everyone live in the project, in one thread (never one thread per agent)\nsay post (body) a general thread: for people, and for context\n (subject is optional: without one, the first line of the body)\n```\n\nAgents have names. Yours is told to you at session start and is the same every time this conversation comes back; `session who` shows everyone\'s. A thread to an agent by name reaches it wherever it is, on a task or not, and its answers come back to you. If two live agents share a name, use the session id from `session who`.\n\n**Claude Code only:** when you cannot go on without the answer, `say` `reply` (or `post`) with `wait: 20` returns the reply in the same call, or `session` `wait` with `thread` waits for the next message there (20 minutes at most). This is rare; the other agent may not read your letter for a long time. **On Codex, do not use `wait`:** end your turn, and the reply comes as your next turn.\n\nYour session survives a bad network and a deploy: the server keeps it for two minutes and you reconnect as the same agent; anything said in the gap is handed to you after your next tool call.\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan\'s notes, decisions in the description. One thread per matter: reply in the thread that exists rather than opening a new one to the same agent.\n\n## Etiquette\n\n- Each conversation has its own session and inbox in each project. Identified conversations can share a checkout; separate worktrees are still useful for keeping code edits independent.\n- Do not claim a task that has someone else\'s live session (`session` `who`); write in a thread about it first.\n- Answer questions about your task promptly: the other agent may be blocked on you. Answer them once.\n- Report progress in the plan\'s notes, not in threads. Threads are for asking and deciding, not for status.\n- If you stop working on a task, end the session so nobody waits on you.\n';
3389
3389
 
3390
3390
  // skills/slop-working.md
3391
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";
@@ -3409,7 +3409,7 @@ function installSkills({ force = false } = {}) {
3409
3409
  }
3410
3410
 
3411
3411
  // src/commands/auth.ts
3412
- import { spawn } from "child_process";
3412
+ import { spawn as spawn2 } from "child_process";
3413
3413
  import { platform as platform2 } from "os";
3414
3414
 
3415
3415
  // ../../node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/classic/external.js
@@ -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, resolve5) {
8441
+ function isRecursive(inst, stack, resolve6) {
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, resolve5) {
8448
8448
  let result = NONE;
8449
8449
  const check2 = (child) => {
8450
8450
  if (result !== PROVEN && child?._zod) {
8451
- const answer = isRecursive(child, stack, resolve5);
8451
+ const answer = isRecursive(child, stack, resolve6);
8452
8452
  if (answer > result)
8453
8453
  result = answer;
8454
8454
  }
@@ -8459,7 +8459,7 @@ function isRecursive(inst, stack, resolve5) {
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, resolve5) : NONE;
8462
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve6) : NONE;
8463
8463
  if (child > answer)
8464
8464
  answer = child;
8465
8465
  }
@@ -8523,7 +8523,7 @@ function isRecursive(inst, stack, resolve5) {
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 ?? (resolve5 ? inst._zod.innerType : void 0);
8526
+ const inner = def._cachedInner ?? (resolve6 ? inst._zod.innerType : void 0);
8527
8527
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
8528
8528
  break;
8529
8529
  }
@@ -23486,11 +23486,13 @@ var SessionClaimInput = external_exports.object({
23486
23486
  });
23487
23487
  var ConnectTicket = external_exports.object({ ticket: external_exports.string(), path: external_exports.string() });
23488
23488
  var AskInput = external_exports.object({ body: external_exports.string().trim().min(1, "Say something").max(4e3) });
23489
+ var ThreadPush = external_exports.object({ thread: external_exports.object({ id: Id, subject: external_exports.string(), taskKey: external_exports.string().nullable(), to: external_exports.string().nullable().optional() }), message: ThreadMessage });
23490
+ var ThreadSessionPush = ThreadPush.extend({ type: external_exports.literal("thread") });
23489
23491
  var SessionPush = external_exports.discriminatedUnion("type", [
23490
23492
  external_exports.object({ type: external_exports.literal("hello"), session: Session }),
23491
23493
  external_exports.object({ type: external_exports.literal("event"), record: TaskEventRecord }),
23492
23494
  /** A message in a thread about the task this session is on. */
23493
- 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 }),
23495
+ ThreadSessionPush,
23494
23496
  /** The room is closing this session (someone ran `slop session end`). */
23495
23497
  external_exports.object({ type: external_exports.literal("end") }),
23496
23498
  /** Reachability was switched from the web. Off releases a waiting agent: nothing can reach it any more. */
@@ -23498,6 +23500,12 @@ var SessionPush = external_exports.discriminatedUnion("type", [
23498
23500
  /** To board watchers: something in the project changed; refetch. */
23499
23501
  external_exports.object({ type: external_exports.literal("changed"), scope: external_exports.string() })
23500
23502
  ]);
23503
+ var SessionMessagesInput = external_exports.object({ cursor: external_exports.coerce.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional() });
23504
+ var SessionMessages = external_exports.object({
23505
+ cursor: external_exports.number().int().nonnegative(),
23506
+ hasMore: external_exports.boolean(),
23507
+ pushes: external_exports.array(ThreadSessionPush)
23508
+ });
23501
23509
 
23502
23510
  // ../contract/src/work/agent-names.ts
23503
23511
  var AGENT_NAMES = [
@@ -23862,6 +23870,7 @@ var ApiResponses = {
23862
23870
  Bindings: external_exports.array(Binding),
23863
23871
  ConnectTicket,
23864
23872
  Sessions: external_exports.array(Session),
23873
+ SessionMessages,
23865
23874
  EndedSessions: external_exports.object({ ended: external_exports.number().int().nonnegative() }),
23866
23875
  ReachableSession: external_exports.object({ ok: external_exports.literal(true), reachable: external_exports.boolean() }),
23867
23876
  Threads: external_exports.array(Thread),
@@ -23924,6 +23933,7 @@ registerResponseContract("POST", ["/projects/:projectId/bindings"], ApiResponses
23924
23933
  registerResponseContract("POST", ["/projects/:projectId/watch/ticket", "/projects/:projectId/session/ticket", "/projects/:projectId/tasks/:ref/session/ticket"], ApiResponses.ConnectTicket);
23925
23934
  registerResponseContract("POST", ["/projects/:projectId/session/end", "/projects/:projectId/tasks/:ref/session/end"], ApiResponses.EndedSessions);
23926
23935
  registerResponseContract("GET", ["/projects/:projectId/sessions"], ApiResponses.Sessions);
23936
+ registerResponseContract("GET", ["/projects/:projectId/session/messages"], ApiResponses.SessionMessages);
23927
23937
  registerResponseContract("POST", ["/projects/:projectId/sessions/:id/reachable"], ApiResponses.ReachableSession);
23928
23938
  registerResponseContract("GET", ["/orgs/:orgId/threads"], ApiResponses.ProjectThreads);
23929
23939
  registerResponseContract("GET", ["/projects/:projectId/threads"], ApiResponses.Threads);
@@ -23941,6 +23951,9 @@ function assertApiResponseContract(method, path2, schema) {
23941
23951
  if (requireApiResponseSchema(method, path2) !== schema) throw new Error("The response contract does not match this API endpoint");
23942
23952
  }
23943
23953
 
23954
+ // src/platform/api.ts
23955
+ import { resolve as resolve5 } from "path";
23956
+
23944
23957
  // src/platform/config.ts
23945
23958
  import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
23946
23959
  import { homedir as homedir2 } from "os";
@@ -23975,43 +23988,100 @@ function resolveSession() {
23975
23988
  }
23976
23989
 
23977
23990
  // src/platform/session-file.ts
23978
- import { createHash } from "crypto";
23979
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
23980
- import { dirname as dirname2, join as join3 } from "path";
23981
- var buildSessionsDir = () => join3(dirname2(buildConfigPath()), "sessions");
23991
+ import { createHash as createHash2 } from "crypto";
23992
+ import { readFileSync as readFileSync3, realpathSync as realpathSync2, rmSync as rmSync3, statSync, readdirSync } from "fs";
23993
+ import { dirname as dirname3, join as join3 } from "path";
23994
+
23995
+ // src/platform/local-state.ts
23996
+ import { createHash, randomUUID } from "crypto";
23997
+ import { linkSync, mkdirSync as mkdirSync3, realpathSync, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
23998
+ import { dirname as dirname2, resolve } from "path";
23999
+ function generateLocalStateKey(cwd, owner) {
24000
+ let directory = resolve(cwd);
24001
+ try {
24002
+ directory = realpathSync(directory);
24003
+ } catch {
24004
+ }
24005
+ return createHash("sha256").update(owner === void 0 ? directory : JSON.stringify([directory, owner])).digest("hex").slice(0, 32);
24006
+ }
24007
+ function persistLocalState(path2, text, overwrite = true) {
24008
+ mkdirSync3(dirname2(path2), { recursive: true });
24009
+ const temporary = `${path2}.${randomUUID()}.writing`;
24010
+ try {
24011
+ writeFileSync3(temporary, text, { flag: "wx", mode: 384 });
24012
+ if (overwrite) renameSync(temporary, path2);
24013
+ else {
24014
+ try {
24015
+ linkSync(temporary, path2);
24016
+ } catch (error62) {
24017
+ if (error62.code !== "EEXIST") throw error62;
24018
+ }
24019
+ }
24020
+ } finally {
24021
+ rmSync2(temporary, { force: true });
24022
+ }
24023
+ }
24024
+
24025
+ // src/platform/session-file.ts
24026
+ var buildSessionsDir = () => join3(dirname3(buildConfigPath()), "sessions");
23982
24027
  var resolveRealCwd = (cwd) => {
23983
24028
  try {
23984
- return realpathSync(cwd);
24029
+ return realpathSync2(cwd);
23985
24030
  } catch {
23986
24031
  return cwd;
23987
24032
  }
23988
24033
  };
23989
- var buildSessionPath = (cwd) => join3(buildSessionsDir(), `${createHash("sha256").update(resolveRealCwd(cwd)).digest("hex").slice(0, 16)}.txt`);
23990
- function persistRememberedSession(cwd, sessionId) {
23991
- mkdirSync3(buildSessionsDir(), { recursive: true });
23992
- if (sessionId) writeFileSync3(buildSessionPath(cwd), sessionId);
23993
- else rmSync2(buildSessionPath(cwd), { force: true });
24034
+ var buildSessionPath = (cwd, conversation) => conversation ? join3(buildSessionsDir(), `${generateLocalStateKey(cwd, conversation)}.txt`) : join3(buildSessionsDir(), `${createHash2("sha256").update(resolveRealCwd(cwd)).digest("hex").slice(0, 16)}.txt`);
24035
+ function persistRememberedSession(cwd, sessionId, conversation) {
24036
+ if (sessionId) persistLocalState(buildSessionPath(cwd, conversation), sessionId);
24037
+ else rmSync3(buildSessionPath(cwd, conversation), { force: true });
23994
24038
  }
23995
- function fetchRememberedSession(cwd) {
23996
- const path2 = buildSessionPath(cwd);
23997
- return existsSync3(path2) ? readFileSync3(path2, "utf8").trim() || null : null;
24039
+ function fetchRememberedSession(cwd, conversation) {
24040
+ try {
24041
+ return readFileSync3(buildSessionPath(cwd, conversation), "utf8").trim() || null;
24042
+ } catch (error62) {
24043
+ if (error62.code === "ENOENT") return null;
24044
+ throw error62;
24045
+ }
23998
24046
  }
23999
- var buildConversationPath = (cwd) => join3(buildSessionsDir(), `${createHash("sha256").update(resolveRealCwd(cwd)).digest("hex").slice(0, 16)}.conversation.json`);
24047
+ var buildConversationPath = (cwd) => join3(buildSessionsDir(), `${createHash2("sha256").update(resolveRealCwd(cwd)).digest("hex").slice(0, 16)}.conversation.json`);
24000
24048
  function persistRememberedConversation(cwd, conversation) {
24001
- mkdirSync3(buildSessionsDir(), { recursive: true });
24002
- writeFileSync3(buildConversationPath(cwd), JSON.stringify(conversation));
24049
+ const directory = `${buildConversationPath(cwd)}.d`;
24050
+ persistLocalState(join3(directory, `${generateLocalStateKey(cwd, conversation.id)}.json`), JSON.stringify(conversation));
24003
24051
  }
24004
24052
  function fetchRememberedConversation(cwd, notBefore) {
24005
24053
  const path2 = buildConversationPath(cwd);
24054
+ const candidates = [];
24055
+ let paths;
24006
24056
  try {
24007
- if (statSync(path2).mtimeMs < notBefore) return null;
24008
- const parsed = JSON.parse(readFileSync3(path2, "utf8"));
24009
- return typeof parsed.id === "string" && parsed.id ? { id: parsed.id, harness: parsed.harness ?? "other" } : null;
24057
+ paths = readdirSync(`${path2}.d`).map((name) => join3(`${path2}.d`, name));
24010
24058
  } catch {
24011
- return null;
24059
+ paths = [path2];
24060
+ }
24061
+ for (const candidate of paths) {
24062
+ if (candidate.endsWith(".writing")) continue;
24063
+ try {
24064
+ if (statSync(candidate).mtimeMs < notBefore) continue;
24065
+ const parsed = JSON.parse(readFileSync3(candidate, "utf8"));
24066
+ const harness = AgentHarness.safeParse(parsed.harness ?? "other");
24067
+ if (typeof parsed.id === "string" && parsed.id && harness.success) candidates.push({ id: parsed.id, harness: harness.data });
24068
+ } catch {
24069
+ }
24012
24070
  }
24071
+ const distinct = [...new Map(candidates.map((candidate) => [candidate.id, candidate])).values()];
24072
+ return distinct.length === 1 ? distinct[0] : null;
24013
24073
  }
24014
24074
 
24075
+ // src/platform/agent-identity.ts
24076
+ import { randomUUID as randomUUID4 } from "crypto";
24077
+ import { basename as basename3, join as join8, resolve as resolve4 } from "path";
24078
+
24079
+ // src/platform/git.ts
24080
+ import { execFileSync } from "child_process";
24081
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
24082
+ import { existsSync as existsSync3, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
24083
+ import { basename, join as join4, resolve as resolve2 } from "path";
24084
+
24015
24085
  // src/platform/errors.ts
24016
24086
  var CliError = class extends Error {
24017
24087
  constructor(message, hint, code = "error", exitCode = 1) {
@@ -24025,1086 +24095,1370 @@ var CliError = class extends Error {
24025
24095
  exitCode;
24026
24096
  };
24027
24097
 
24028
- // src/platform/api.ts
24029
- function buildApiClient(options) {
24030
- async function sendApiRequest(method, path2, schema, body2) {
24031
- try {
24032
- assertApiResponseContract(method, path2, schema);
24033
- } catch {
24034
- throw new CliError("This request does not match the API contract", "Update slop to the latest version", "internal");
24035
- }
24036
- const headers = { accept: "application/json" };
24037
- if (body2 !== void 0) headers["content-type"] = "application/json";
24038
- if (options.token) headers.authorization = `Bearer ${options.token}`;
24039
- if (client.sessionId) headers["x-slop-session"] = client.sessionId;
24040
- let response;
24041
- try {
24042
- response = await fetch(`${options.baseUrl}/api${path2}`, {
24043
- method,
24044
- headers,
24045
- body: body2 === void 0 ? void 0 : JSON.stringify(body2),
24046
- signal: AbortSignal.timeout(2e4)
24047
- });
24048
- } catch (error62) {
24049
- const reason = error62 instanceof Error && error62.name === "TimeoutError" ? "timed out" : "is unreachable";
24050
- throw new CliError(`${options.baseUrl} ${reason}`, void 0, "network");
24051
- }
24052
- const text = await response.text();
24053
- if (response.ok) {
24054
- try {
24055
- return schema.parse(JSON.parse(text));
24056
- } catch {
24057
- throw new CliError("The server returned an incompatible response", "Update slop to the latest version", "internal");
24058
- }
24059
- }
24060
- let parsed = null;
24061
- try {
24062
- parsed = ApiErrorBody.safeParse(JSON.parse(text));
24063
- } catch {
24064
- }
24065
- if (parsed?.success) {
24066
- const { code, message } = parsed.data.error;
24067
- throw new CliError(message, code === "unauthenticated" ? "slop login" : void 0, code);
24068
- }
24069
- throw new CliError(`${method} ${path2} failed with HTTP ${response.status}`, void 0, "http");
24070
- }
24071
- const client = {
24072
- baseUrl: options.baseUrl,
24073
- sessionId: null,
24074
- get: (path2, schema) => sendApiRequest("GET", path2, schema),
24075
- post: (path2, schema, body2) => sendApiRequest("POST", path2, schema, body2 ?? {}),
24076
- patch: (path2, schema, body2) => sendApiRequest("PATCH", path2, schema, body2),
24077
- put: (path2, schema, body2) => sendApiRequest("PUT", path2, schema, body2),
24078
- delete: (path2, schema) => sendApiRequest("DELETE", path2, schema)
24079
- };
24080
- return client;
24081
- }
24082
- function buildAuthedApiClient() {
24083
- const session = resolveSession();
24084
- if (!session.token) throw new CliError(`Not signed in to ${session.baseUrl}`, "slop login", "unauthenticated");
24085
- const client = buildApiClient(session);
24086
- client.sessionId = fetchRememberedSession(process.cwd());
24087
- return client;
24088
- }
24089
-
24090
- // src/platform/machine.ts
24091
- import { execFileSync } from "child_process";
24092
- import { createHash as createHash2, randomUUID } from "crypto";
24093
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
24094
- import { hostname as hostname3, platform, userInfo } from "os";
24095
- import { dirname as dirname3, join as join4 } from "path";
24096
- function runCommand(command, args) {
24098
+ // src/platform/git.ts
24099
+ function runGit(args, cwd = process.cwd()) {
24097
24100
  try {
24098
- return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
24101
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
24099
24102
  } catch {
24100
24103
  return null;
24101
24104
  }
24102
24105
  }
24103
- function fetchOsMachineId() {
24104
- switch (platform()) {
24105
- case "darwin":
24106
- return /"IOPlatformUUID" = "([^"]+)"/.exec(runCommand("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]) ?? "")?.[1] ?? null;
24107
- case "linux":
24108
- for (const path2 of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
24109
- if (existsSync4(path2)) return readFileSync4(path2, "utf8").trim() || null;
24110
- }
24111
- return null;
24112
- case "win32":
24113
- return /MachineGuid\s+REG_SZ\s+(\S+)/.exec(
24114
- runCommand("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]) ?? ""
24115
- )?.[1] ?? null;
24116
- default:
24117
- return null;
24118
- }
24106
+ function fetchRepo() {
24107
+ const commonDir = runGit(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
24108
+ if (!commonDir) return null;
24109
+ const worktree = runGit(["rev-parse", "--show-toplevel"]);
24110
+ const root = basename(commonDir) === ".git" ? resolve2(commonDir, "..") : worktree ?? commonDir;
24111
+ return { root, worktree: worktree ?? root, linkPath: join4(commonDir, "slop.json") };
24119
24112
  }
24120
- function resolvePersistedMachineId() {
24121
- const path2 = join4(dirname3(buildConfigPath()), "machine-id");
24122
- if (existsSync4(path2)) return readFileSync4(path2, "utf8").trim();
24123
- const id = randomUUID();
24124
- mkdirSync4(dirname3(path2), { recursive: true, mode: 448 });
24125
- writeFileSync4(path2, `${id}
24126
- `, { mode: 384 });
24127
- return id;
24113
+ function fetchCurrentBranch(cwd = process.cwd()) {
24114
+ const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
24115
+ return branch && branch !== "HEAD" ? branch : null;
24128
24116
  }
24129
- function buildMachineInfo() {
24130
- const raw = `${fetchOsMachineId() ?? resolvePersistedMachineId()}:${userInfo().username}`;
24131
- return {
24132
- fingerprint: createHash2("sha256").update(raw).digest("hex"),
24133
- name: hostname3().replace(/\.local$/, ""),
24134
- platform: platform()
24135
- };
24117
+ function requireRepo() {
24118
+ const repo = fetchRepo();
24119
+ if (!repo) throw new CliError("Not inside a git repository", "cd into your repository, or pass --project KEY", "no_repo");
24120
+ return repo;
24136
24121
  }
24137
-
24138
- // src/platform/output.ts
24139
- var jsonMode = false;
24140
- var updateJsonMode = (on) => {
24141
- jsonMode = on;
24142
- };
24143
- var isJsonMode = () => jsonMode;
24144
- function emitOutput(value, human) {
24145
- process.stdout.write(jsonMode ? `${JSON.stringify(value, null, 2)}
24146
- ` : `${human()}
24147
- `);
24122
+ function fetchRepoLink(repo) {
24123
+ try {
24124
+ return JSON.parse(readFileSync4(repo.linkPath, "utf8"));
24125
+ } catch {
24126
+ return null;
24127
+ }
24148
24128
  }
24149
- function exitWithError(error62) {
24150
- const cli = error62 instanceof CliError ? error62 : null;
24151
- const message = error62 instanceof Error ? error62.message : String(error62);
24152
- if (jsonMode) {
24153
- process.stderr.write(`${JSON.stringify({ error: { code: cli?.code ?? "error", message, hint: cli?.hint } })}
24154
- `);
24155
- } else {
24156
- process.stderr.write(`error: ${message}
24157
- `);
24158
- if (cli?.hint) process.stderr.write(` try: ${cli.hint}
24129
+ function persistRepoLink(repo, link) {
24130
+ writeFileSync4(repo.linkPath, `${JSON.stringify(link, null, 2)}
24159
24131
  `);
24160
- }
24161
- process.exit(cli?.exitCode ?? 1);
24162
24132
  }
24163
- function formatTable(rows) {
24164
- const widths = [];
24165
- for (const row of rows) row.forEach((cell, index) => widths[index] = Math.max(widths[index] ?? 0, cell.length));
24166
- return rows.map((row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index])).join(" ")).join("\n");
24133
+ function deleteTaskLink(repo) {
24134
+ if (existsSync3(repo.linkPath)) rmSync4(repo.linkPath);
24167
24135
  }
24168
- var formatStatusLabel = (statuses, key) => statuses.find((status) => status.key === key)?.label ?? key;
24169
- var formatTaskLine = (statuses) => (task) => [
24170
- task.key,
24171
- formatStatusLabel(statuses, task.status),
24172
- TASK_SEVERITY_LABELS[task.severity],
24173
- task.assignee?.name ?? "-",
24174
- task.title
24175
- ];
24176
- function formatTaskDetail(task, url2, statuses) {
24177
- const lines = [
24178
- `${task.key} ${task.title}`,
24179
- `status: ${formatStatusLabel(statuses, task.status)} severity: ${TASK_SEVERITY_LABELS[task.severity]} assignee: ${task.assignee?.name ?? "nobody"} by: ${task.createdBy.name}`,
24180
- url2
24181
- ];
24182
- if (task.links.length > 0) lines.push(`links: ${task.links.map((link) => `${link.title} <${link.url}>`).join(", ")}`);
24183
- if (task.archivedAt) lines.push("archived");
24184
- if (task.items.length > 0) {
24185
- lines.push("", `plan ${task.progress.done}/${task.progress.total} done`);
24186
- task.items.forEach((item, index) => lines.push(` ${index + 1}. [${item.done ? "x" : " "}] ${item.title}${item.note ? ` (${item.note.split("\n")[0]})` : ""}`));
24136
+ function normalizeRemoteUrl(url2) {
24137
+ const scp = /^[^@/]+@([^:/]+):(.+)$/.exec(url2);
24138
+ const bare = scp ? `${scp[1]}/${scp[2]}` : url2.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "");
24139
+ return bare.replace(/\.git$/, "").replace(/\/+$/, "");
24140
+ }
24141
+ function buildRepoDescription(repo, existing) {
24142
+ const roots = runGit(["rev-list", "--max-parents=0", "HEAD"], repo.root);
24143
+ const remote = runGit(["remote", "get-url", "origin"], repo.root);
24144
+ const remoteUrl = remote ? normalizeRemoteUrl(remote) : null;
24145
+ let fingerprint;
24146
+ let localRepoId;
24147
+ if (roots) {
24148
+ fingerprint = createHash3("sha256").update(roots.split("\n").sort().join("\n")).digest("hex");
24149
+ } else {
24150
+ localRepoId = existing?.localRepoId ?? randomUUID2();
24151
+ fingerprint = `local:${localRepoId}`;
24187
24152
  }
24188
- if (task.description) lines.push("", task.description);
24189
- if (task.notes) lines.push("", "notes:", task.notes);
24190
- return lines.join("\n");
24153
+ return {
24154
+ info: { fingerprint, name: remoteUrl?.split("/").slice(-1)[0] ?? basename(repo.root), remoteUrl, rootPath: repo.root },
24155
+ localRepoId
24156
+ };
24191
24157
  }
24192
24158
 
24193
- // src/commands/auth.ts
24194
- function openBrowser(url2) {
24195
- const [command, args] = platform2() === "darwin" ? ["open", [url2]] : platform2() === "win32" ? ["cmd", ["/c", "start", "", url2]] : ["xdg-open", [url2]];
24159
+ // src/platform/harness.ts
24160
+ import { execFileSync as execFileSync3 } from "child_process";
24161
+ import { closeSync, openSync, readdirSync as readdirSync3, readSync, readlinkSync, realpathSync as realpathSync3, statSync as statSync3 } from "fs";
24162
+ import { basename as basename2, join as join7 } from "path";
24163
+
24164
+ // src/platform/harness-wake.ts
24165
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
24166
+ import { join as join6 } from "path";
24167
+ import { connect } from "net";
24168
+ import { spawn } from "child_process";
24169
+ import { randomUUID as randomUUID3 } from "crypto";
24170
+
24171
+ // src/platform/homes.ts
24172
+ import { execFileSync as execFileSync2 } from "child_process";
24173
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync2 } from "fs";
24174
+ import { homedir as homedir3 } from "os";
24175
+ import { join as join5, resolve as resolve3 } from "path";
24176
+ var deduplicateStrings = (paths) => [...new Set(paths.map((path2) => resolve3(path2)))];
24177
+ function fetchAncestorEnvironments() {
24178
+ if (process.platform === "win32") return [];
24179
+ const seen = [];
24196
24180
  try {
24197
- spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => {
24198
- }).unref();
24181
+ if (process.platform === "linux") {
24182
+ let pid2 = process.ppid;
24183
+ for (let hop = 0; hop < 6 && pid2 > 1; hop += 1) {
24184
+ seen.push(readFileSync5(`/proc/${pid2}/environ`, "latin1").replace(/\0/g, " "));
24185
+ const stat = readFileSync5(`/proc/${pid2}/stat`, "utf8");
24186
+ pid2 = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
24187
+ }
24188
+ for (const entry of readdirSync2("/proc")) {
24189
+ if (!/^\d+$/.test(entry)) continue;
24190
+ try {
24191
+ seen.push(readFileSync5(`/proc/${entry}/environ`, "latin1").replace(/\0/g, " "));
24192
+ } catch {
24193
+ }
24194
+ }
24195
+ return seen;
24196
+ }
24197
+ const runPs = (args) => execFileSync2("ps", args, { encoding: "utf8", maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "ignore"] });
24198
+ let pid = process.ppid;
24199
+ for (let hop = 0; hop < 6 && pid > 1; hop += 1) {
24200
+ seen.push(runPs(["eww", "-o", "command=", "-p", String(pid)]));
24201
+ pid = Number(runPs(["-o", "ppid=", "-p", String(pid)]).trim());
24202
+ }
24203
+ seen.push(runPs(["axeww", "-o", "command="]));
24199
24204
  } catch {
24200
24205
  }
24206
+ return seen;
24201
24207
  }
24202
- var waitForMilliseconds = (ms) => new Promise((done) => setTimeout(done, ms));
24203
- var isHeadless = () => Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY) || platform2() === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
24204
- function registerAuthCommands(program3) {
24205
- program3.command("login").description("Sign this machine in. Opens the web app, where you approve the login. On a VM or over SSH, open the printed link on any device.").option("--url <url>", "the Slopware Tasks instance to sign in to").option("--no-browser", "print the link instead of opening it").action(async (options) => {
24206
- const baseUrl = (options.url || process.env.SLOP_URL || fetchConfig()?.baseUrl || DEFAULT_URL).replace(/\/+$/, "");
24207
- const api = buildApiClient({ baseUrl, token: null });
24208
- const started = await api.post("/cli/auth/start", ApiResponses.StartCliAuthResult, { machine: buildMachineInfo() });
24209
- const remote = isHeadless();
24210
- process.stderr.write(`
24211
- Approve this login in a browser${remote ? " on any device (this machine has no browser; your laptop or phone is fine)" : ""}:
24212
- ${started.verifyUrl}
24213
-
24214
- Confirm the code matches: ${started.userCode}
24215
-
24216
- `);
24217
- if (options.browser && !remote) openBrowser(started.verifyUrl);
24218
- const deadline = Date.now() + started.expiresIn * 1e3;
24219
- while (Date.now() < deadline) {
24220
- await waitForMilliseconds(started.interval * 1e3);
24221
- const poll = await api.post("/cli/auth/poll", ApiResponses.PollCliAuthResult, { deviceCode: started.deviceCode });
24222
- if (poll.status === "pending") continue;
24223
- if (poll.status === "denied") throw new CliError("The login was denied in the web app");
24224
- if (poll.status === "expired") break;
24225
- const me = await buildApiClient({ baseUrl, token: poll.token }).get("/me", ApiResponses.Me);
24226
- persistConfig({ baseUrl, token: poll.token, email: me.user.email });
24227
- const skills = installSkills();
24228
- emitOutput({ ok: true, user: me.user, machine: me.machine, baseUrl, skills }, () => `Signed in as ${me.user.email} on ${me.machine?.name ?? "this machine"}.${skills.length ? `
24229
- Agent skills installed in ~/.claude/skills and ~/.agents/skills. Next: slop mcp install (the board as tools for Claude Code and Codex).` : ""}`);
24230
- return;
24231
- }
24232
- throw new CliError("The login request expired before it was approved", "slop login");
24233
- });
24234
- program3.command("logout").description("Sign this machine out and revoke its token.").action(async () => {
24235
- const session = resolveSession();
24236
- if (session.token) await buildApiClient(session).post("/cli/auth/logout", ApiResponses.Ok).catch(() => {
24237
- });
24238
- deleteConfig();
24239
- emitOutput({ ok: true }, () => "Signed out.");
24240
- });
24241
- program3.command("whoami").description("Show the signed-in user, machine and instance.").action(async () => {
24242
- const api = buildAuthedApiClient();
24243
- const me = await api.get("/me", ApiResponses.Me);
24244
- emitOutput({ ...me, baseUrl: api.baseUrl }, () => `${me.user.name} <${me.user.email}>
24245
- machine: ${me.machine?.name ?? "-"}
24246
- instance: ${api.baseUrl}`);
24247
- });
24248
- }
24249
-
24250
- // src/commands/guide.ts
24251
- var AGENT_GUIDE = `## Task tracking
24252
-
24253
- This repository is linked to a Slopware Tasks board. Use the \`slop\` CLI to keep it current.
24254
-
24255
- - When you find work that should be tracked (a bug, a follow-up, a TODO you are not doing now), create a task:
24256
- \`slop task create "Short imperative title" -d "What, where, why" -s <low|medium|high|critical>\`
24257
- - Before starting tracked work: \`slop task list\`, then \`slop task start <KEY>\`.
24258
- - When it is finished and verified: \`slop task done <KEY>\`.
24259
- - 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 -\`.
24260
- - 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 on their own: idle, as a turn of yours; mid-turn, with your next tool result. Messages are letters, not chat: the other agent reads yours when it gets to it, its reply may predate your latest, so write once and completely, answer a pile of messages with one reply, and reply only when it changes something (never to acknowledge). Do not wait for replies; end your turn. 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\`.
24261
- - 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.
24262
- - 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.
24263
- - Add \`--json\` to any command for machine-readable output. \`slop task --help\` lists everything.
24264
- - When you open a pull request for a task, link it: \`slop task link <KEY> <pr-url>\`.
24265
- - Columns are the project's own: \`slop statuses\` lists them; \`slop task move <KEY> "<column>"\` takes a key or label. \`slop task start\` / \`slop task done\` go to the first "in progress" / "done" column. Finished work is archived from the board (\`slop task archive <KEY>\`), not deleted. Do not delete tasks you did not create.
24266
- `;
24267
- function registerGuideCommand(program3) {
24268
- program3.command("guide").description("Print a snippet for AGENTS.md / CLAUDE.md that teaches an agent to use this CLI.").action(() => emitOutput({ markdown: AGENT_GUIDE }, () => AGENT_GUIDE));
24208
+ var environments = null;
24209
+ function findHomesInUse(variable) {
24210
+ environments ??= fetchAncestorEnvironments();
24211
+ const found = [];
24212
+ const pattern = new RegExp(`(?:^|\\s)${variable}=(\\S+)`, "g");
24213
+ for (const text of environments) for (const match of text.matchAll(pattern)) found.push(match[1] ?? "");
24214
+ return found.filter(Boolean);
24269
24215
  }
24270
-
24271
- // src/platform/git.ts
24272
- import { execFileSync as execFileSync2 } from "child_process";
24273
- import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
24274
- import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
24275
- import { basename, join as join5, resolve } from "path";
24276
- function runGit(args, cwd = process.cwd()) {
24216
+ var findDirectoriesNamed = (root, prefix) => {
24277
24217
  try {
24278
- return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
24218
+ return readdirSync2(root, { withFileTypes: true }).filter((entry) => entry.name.startsWith(prefix) && (entry.isDirectory() || entry.isSymbolicLink())).map((entry) => join5(root, entry.name)).filter((path2) => {
24219
+ try {
24220
+ return statSync2(path2).isDirectory();
24221
+ } catch {
24222
+ return false;
24223
+ }
24224
+ });
24279
24225
  } catch {
24280
- return null;
24226
+ return [];
24281
24227
  }
24228
+ };
24229
+ var hasAnyEntry = (dir, files) => files.some((file2) => existsSync4(join5(dir, file2)));
24230
+ function fetchClaudeHomes(root = homedir3(), inUse = findHomesInUse) {
24231
+ if (process.env.SLOP_CLAUDE_HOME) return [process.env.SLOP_CLAUDE_HOME];
24232
+ const named = process.env.CLAUDE_CONFIG_DIR ? [process.env.CLAUDE_CONFIG_DIR] : [];
24233
+ return deduplicateStrings([join5(root, ".claude"), ...named, ...inUse("CLAUDE_CONFIG_DIR"), ...findDirectoriesNamed(root, ".claude").filter((dir) => hasAnyEntry(dir, ["settings.json", ".claude.json", "sessions"]))]);
24282
24234
  }
24283
- function fetchRepo() {
24284
- const commonDir = runGit(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
24285
- if (!commonDir) return null;
24286
- const worktree = runGit(["rev-parse", "--show-toplevel"]);
24287
- const root = basename(commonDir) === ".git" ? resolve(commonDir, "..") : worktree ?? commonDir;
24288
- return { root, worktree: worktree ?? root, linkPath: join5(commonDir, "slop.json") };
24289
- }
24290
- function fetchCurrentBranch(cwd = process.cwd()) {
24291
- const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
24292
- return branch && branch !== "HEAD" ? branch : null;
24293
- }
24294
- function requireRepo() {
24295
- const repo = fetchRepo();
24296
- if (!repo) throw new CliError("Not inside a git repository", "cd into your repository, or pass --project KEY", "no_repo");
24297
- return repo;
24235
+ function fetchCodexHomes(root = homedir3(), inUse = findHomesInUse) {
24236
+ if (process.env.SLOP_CODEX_HOME) return [process.env.SLOP_CODEX_HOME];
24237
+ const named = process.env.CODEX_HOME ? [process.env.CODEX_HOME] : [];
24238
+ return deduplicateStrings([join5(root, ".codex"), ...named, ...inUse("CODEX_HOME"), ...findDirectoriesNamed(root, ".codex").filter((dir) => hasAnyEntry(dir, ["config.toml", "auth.json", "sessions"]))]);
24298
24239
  }
24299
- function fetchRepoLink(repo) {
24240
+ function resolveOwnHarnessHome(variable) {
24241
+ if (process.platform === "win32") return null;
24300
24242
  try {
24301
- return JSON.parse(readFileSync5(repo.linkPath, "utf8"));
24243
+ let pid = process.ppid;
24244
+ for (let hop = 0; hop < 6 && pid > 1; hop += 1) {
24245
+ const text = process.platform === "linux" ? readFileSync5(`/proc/${pid}/environ`, "latin1").replace(/\0/g, " ") : execFileSync2("ps", ["eww", "-o", "command=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
24246
+ const match = new RegExp(`(?:^|\\s)${variable}=(\\S+)`).exec(text);
24247
+ if (match?.[1]) return resolve3(match[1]);
24248
+ if (process.platform === "linux") {
24249
+ const stat = readFileSync5(`/proc/${pid}/stat`, "utf8");
24250
+ pid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
24251
+ } else pid = Number(execFileSync2("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim());
24252
+ }
24302
24253
  } catch {
24303
- return null;
24304
- }
24305
- }
24306
- function persistRepoLink(repo, link) {
24307
- writeFileSync5(repo.linkPath, `${JSON.stringify(link, null, 2)}
24308
- `);
24309
- }
24310
- function deleteTaskLink(repo) {
24311
- if (existsSync5(repo.linkPath)) rmSync3(repo.linkPath);
24312
- }
24313
- function normalizeRemoteUrl(url2) {
24314
- const scp = /^[^@/]+@([^:/]+):(.+)$/.exec(url2);
24315
- const bare = scp ? `${scp[1]}/${scp[2]}` : url2.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "");
24316
- return bare.replace(/\.git$/, "").replace(/\/+$/, "");
24317
- }
24318
- function buildRepoDescription(repo, existing) {
24319
- const roots = runGit(["rev-list", "--max-parents=0", "HEAD"], repo.root);
24320
- const remote = runGit(["remote", "get-url", "origin"], repo.root);
24321
- const remoteUrl = remote ? normalizeRemoteUrl(remote) : null;
24322
- let fingerprint;
24323
- let localRepoId;
24324
- if (roots) {
24325
- fingerprint = createHash3("sha256").update(roots.split("\n").sort().join("\n")).digest("hex");
24326
- } else {
24327
- localRepoId = existing?.localRepoId ?? randomUUID2();
24328
- fingerprint = `local:${localRepoId}`;
24329
24254
  }
24330
- return {
24331
- info: { fingerprint, name: remoteUrl?.split("/").slice(-1)[0] ?? basename(repo.root), remoteUrl, rootPath: repo.root },
24332
- localRepoId
24333
- };
24255
+ return null;
24334
24256
  }
24335
24257
 
24336
- // src/commands/projects.ts
24337
- async function fetchProjectByKey(api, key) {
24338
- const projects = await api.get("/projects", ApiResponses.Projects);
24339
- const matches = projects.filter((project) => project.key === key.toUpperCase() || project.id === key);
24340
- if (matches.length === 1) return matches[0];
24341
- if (matches.length > 1) {
24342
- throw new CliError(`More than one of your organizations has a project with the key ${key.toUpperCase()}:
24343
- ${matches.map((project) => ` ${project.id} ${project.name}`).join("\n")}`, "slop link <project id>");
24258
+ // src/platform/harness-wake.ts
24259
+ function fetchHarnessRegistration(pid = process.ppid) {
24260
+ for (const home of fetchClaudeHomes()) {
24261
+ const path2 = join6(home, "sessions", `${pid}.json`);
24262
+ if (!existsSync5(path2)) continue;
24263
+ try {
24264
+ return JSON.parse(readFileSync6(path2, "utf8"));
24265
+ } catch {
24266
+ return null;
24267
+ }
24344
24268
  }
24345
- throw new CliError(`No project with the key ${key.toUpperCase()}`, "slop projects");
24269
+ return null;
24346
24270
  }
24347
- async function resolveProject(api, explicit) {
24348
- if (explicit) return fetchProjectByKey(api, explicit);
24349
- const repo = fetchRepo();
24350
- const link = repo ? fetchRepoLink(repo) : null;
24351
- if (!link) throw new CliError("This repository is not linked to a project", "slop link <PROJECT_KEY> (or pass --project KEY)", "not_linked");
24352
- if (link.baseUrl !== api.baseUrl) {
24353
- throw new CliError(`This repository is linked to ${link.baseUrl}, but you are signed in to ${api.baseUrl}`, `slop login --url ${link.baseUrl}`);
24271
+ function resolveClaudeSession(pid = Number(process.env.SLOP_HARNESS_PID) || process.ppid) {
24272
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
24273
+ if (sessionId) {
24274
+ const registration2 = fetchHarnessRegistration(pid);
24275
+ return { sessionId, socketPath: process.env.CLAUDE_CODE_MESSAGING_SOCKET || (registration2?.sessionId === sessionId ? registration2.messagingSocketPath : null) || null };
24354
24276
  }
24355
- return { id: link.projectId, key: link.projectKey };
24356
- }
24357
- async function listMembers(api, projectId) {
24358
- const project = await api.get(`/projects/${projectId}`, ApiResponses.Project);
24359
- return (await api.get(`/orgs/${project.orgId}/members`, ApiResponses.MembersResult)).members;
24277
+ const registration = fetchHarnessRegistration(pid);
24278
+ return registration?.sessionId ? { sessionId: registration.sessionId, socketPath: registration.messagingSocketPath ?? null } : null;
24360
24279
  }
24361
- function registerProjectCommands(program3) {
24362
- program3.command("projects").description("List the projects you can reach.").action(async () => {
24363
- const projects = await buildAuthedApiClient().get("/projects", ApiResponses.Projects);
24364
- emitOutput(
24365
- projects,
24366
- () => projects.length === 0 ? 'No projects yet. Create one: slop project create "My project"' : formatTable([["KEY", "NAME", "OPEN"], ...projects.map((project2) => [project2.key, project2.name, String(project2.openTaskCount)])])
24367
- );
24368
- });
24369
- program3.command("members").description("List the people tasks in this project can be assigned to.").option("-p, --project <key>", "project key (default: the linked project)").action(async (options) => {
24370
- const api = buildAuthedApiClient();
24371
- const members2 = await listMembers(api, (await resolveProject(api, options.project)).id);
24372
- emitOutput(members2, () => formatTable([["NAME", "EMAIL", "ROLE"], ...members2.map((member) => [member.name, member.email, member.role])]));
24373
- });
24374
- program3.command("statuses").alias("columns").description("The linked project's columns, in board order.").option("-p, --project <key>", "project key (default: the linked project)").action(async (options) => {
24375
- const api = buildAuthedApiClient();
24376
- const target = await resolveProject(api, options.project);
24377
- const statuses = (await api.get(`/projects/${target.id}`, ApiResponses.Project)).statuses;
24378
- emitOutput(statuses, () => formatTable([["KEY", "LABEL", "CATEGORY"], ...statuses.map((status2) => [status2.key, status2.label, status2.category])]));
24379
- });
24380
- const project = program3.command("project").description("Manage projects.");
24381
- const status = project.command("status").alias("column").description("Add, rename, reorder or remove the project's columns.");
24382
- const fetchResolvedProject = async (api, key) => api.get(`/projects/${(await resolveProject(api, key)).id}`, ApiResponses.Project);
24383
- const requireStatusByRef = (target, ref) => {
24384
- const found = findStatusByKeyOrLabel(target.statuses, ref);
24385
- if (!found) throw new CliError(`"${ref}" is not a column of ${target.key}`, "slop statuses");
24386
- return found;
24280
+ function wakeHarness(text, pid = Number(process.env.SLOP_HARNESS_PID) || process.ppid, expectedSessionId) {
24281
+ const session = resolveClaudeSession(pid);
24282
+ if (!session?.socketPath || expectedSessionId && session.sessionId !== expectedSessionId) return Promise.resolve(false);
24283
+ const frame = {
24284
+ uuid: randomUUID3(),
24285
+ session_id: session.sessionId,
24286
+ type: "user",
24287
+ parent_tool_use_id: null,
24288
+ message: { role: "user", content: text }
24387
24289
  };
24388
- const formatStatusLines = (statuses) => statuses.map((candidate) => `${candidate.key} (${candidate.label}, ${candidate.category})`).join("\n");
24389
- status.command("add <label>").description("Add a column at the end.").option("-p, --project <key>").option("-c, --category <category>", STATUS_CATEGORIES.join(" | "), "todo").action(async (label, options) => {
24390
- const api = buildAuthedApiClient();
24391
- if (!STATUS_CATEGORIES.includes(options.category)) throw new CliError(`Unknown category "${options.category}"`, `use one of: ${STATUS_CATEGORIES.join(", ")}`);
24392
- const target = await fetchResolvedProject(api, options.project);
24393
- const statuses = await api.post(`/projects/${target.id}/statuses`, ApiResponses.ProjectStatuses, { label, category: options.category });
24394
- emitOutput(statuses, () => formatStatusLines(statuses));
24395
- });
24396
- status.command("rename <column> <label>").description("Rename a column (its key stays).").option("-p, --project <key>").option("-c, --category <category>", STATUS_CATEGORIES.join(" | ")).action(async (ref, label, options) => {
24397
- const api = buildAuthedApiClient();
24398
- const target = await fetchResolvedProject(api, options.project);
24399
- const statuses = await api.patch(`/projects/${target.id}/statuses/${requireStatusByRef(target, ref).id}`, ApiResponses.ProjectStatuses, { label, category: options.category });
24400
- emitOutput(statuses, () => formatStatusLines(statuses));
24401
- });
24402
- status.command("remove <column>").description("Remove a column; its tasks move to --to.").option("-p, --project <key>").option("--to <column>", "where the column's tasks go").action(async (ref, options) => {
24403
- const api = buildAuthedApiClient();
24404
- const target = await fetchResolvedProject(api, options.project);
24405
- const moveTo = options.to ? requireStatusByRef(target, options.to).key : null;
24406
- const statuses = await api.delete(`/projects/${target.id}/statuses/${requireStatusByRef(target, ref).id}${moveTo ? `?moveTo=${encodeURIComponent(moveTo)}` : ""}`, ApiResponses.ProjectStatuses);
24407
- emitOutput(statuses, () => formatStatusLines(statuses));
24408
- });
24409
- status.command("order <columns...>").description("Set the board order: every column, by key or label.").option("-p, --project <key>").action(async (refs, options) => {
24410
- const api = buildAuthedApiClient();
24411
- const target = await fetchResolvedProject(api, options.project);
24412
- const statuses = await api.put(`/projects/${target.id}/statuses/order`, ApiResponses.ProjectStatuses, { ids: refs.map((ref) => requireStatusByRef(target, ref).id) });
24413
- emitOutput(statuses, () => formatStatusLines(statuses));
24290
+ return new Promise((resolve6) => {
24291
+ const socket = connect({ path: session.socketPath });
24292
+ const finishWake = (ok) => {
24293
+ clearTimeout(timer);
24294
+ socket.destroy();
24295
+ resolve6(ok);
24296
+ };
24297
+ const timer = setTimeout(() => finishWake(false), 3e3);
24298
+ socket.on("connect", () => {
24299
+ socket.write(`${JSON.stringify(frame)}
24300
+ `, (error62) => error62 ? finishWake(false) : setTimeout(() => finishWake(true), 200));
24301
+ });
24302
+ socket.on("error", () => finishWake(false));
24414
24303
  });
24415
- project.command("update").description("Change the linked project's name or description.").option("-p, --project <key>", "project key (default: the linked project)").option("-n, --name <name>").option("-d, --description <text>").action(async (options) => {
24416
- const api = buildAuthedApiClient();
24417
- const target = await resolveProject(api, options.project);
24418
- if (options.name === void 0 && options.description === void 0) {
24419
- throw new CliError("Nothing to change", "pass --name or --description");
24304
+ }
24305
+ function wakeCodex(text, threadId, options = {}) {
24306
+ const endpoint = process.env.SLOP_CODEX_APP_SERVER;
24307
+ if (endpoint) return wakeCodexOverWs(text, threadId, endpoint);
24308
+ return new Promise((resolve6) => {
24309
+ let child;
24310
+ try {
24311
+ child = spawn(options.binary ?? "codex", ["queue", "--thread", threadId, "--message", text], {
24312
+ env: options.codexHome ? { ...process.env, CODEX_HOME: options.codexHome } : process.env,
24313
+ stdio: ["ignore", "ignore", "ignore"]
24314
+ });
24315
+ } catch {
24316
+ resolve6(false);
24317
+ return;
24420
24318
  }
24421
- const updated = await api.patch(`/projects/${target.id}`, ApiResponses.Project, { name: options.name, description: options.description });
24422
- emitOutput(updated, () => `Updated ${updated.key} ${updated.name}`);
24423
- });
24424
- project.command("create <name>").description("Create a project.").option("-k, --key <key>", "task key prefix, e.g. WEB (default: derived from the name)").option("-d, --description <text>", "what the project is").option("--org <slug-or-id>", "which org, if you belong to several").action(async (name, options) => {
24425
- const api = buildAuthedApiClient();
24426
- const orgs = await api.get("/orgs", ApiResponses.Orgs);
24427
- const org = options.org ? orgs.find((candidate) => candidate.id === options.org || candidate.slug === options.org) : orgs.length === 1 ? orgs[0] : void 0;
24428
- if (!org) throw new CliError(options.org ? `No org "${options.org}"` : "You belong to several orgs", `pass --org: ${orgs.map((candidate) => candidate.slug).join(", ")}`);
24429
- const key = options.key ?? generateProjectKeyFromName(name);
24430
- if (!key) throw new CliError("Could not derive a key from that name", "pass --key, e.g. --key WEB");
24431
- const project2 = await api.post(`/orgs/${org.id}/projects`, ApiResponses.Project, { name, key, description: options.description ?? "" });
24432
- emitOutput(project2, () => `Created ${project2.key} ${project2.name}
24433
- Link this repository to it: slop link ${project2.key}`);
24434
- });
24435
- program3.command("link <project>").description("Bind this git repository (on this machine) to a project, by key.").action(async (projectKey) => {
24436
- const api = buildAuthedApiClient();
24437
- const repo = requireRepo();
24438
- const project2 = await fetchProjectByKey(api, projectKey);
24439
- const { info, localRepoId } = buildRepoDescription(repo, fetchRepoLink(repo));
24440
- const binding = await api.post(`/projects/${project2.id}/bindings`, ApiResponses.Binding, { repo: info });
24441
- persistRepoLink(repo, { baseUrl: api.baseUrl, projectId: project2.id, projectKey: project2.key, projectName: project2.name, bindingId: binding.id, localRepoId });
24442
- emitOutput({ project: project2, binding }, () => `Linked ${info.name} on ${binding.machine.name} to ${project2.key} ${project2.name}
24443
- Try: slop task create "My first task"`);
24444
- });
24445
- program3.command("unlink").description("Remove this repository's binding.").action(async () => {
24446
- const repo = requireRepo();
24447
- const link = fetchRepoLink(repo);
24448
- if (!link) throw new CliError("This repository is not linked", void 0, "not_linked");
24449
- await buildAuthedApiClient().delete(`/projects/${link.projectId}/bindings/${link.bindingId}`, ApiResponses.Ok).catch(() => {
24319
+ const timer = setTimeout(() => {
24320
+ child.kill();
24321
+ resolve6(false);
24322
+ }, 15e3);
24323
+ child.on("error", () => {
24324
+ clearTimeout(timer);
24325
+ resolve6(false);
24326
+ });
24327
+ child.on("exit", (code) => {
24328
+ clearTimeout(timer);
24329
+ resolve6(code === 0);
24450
24330
  });
24451
- deleteTaskLink(repo);
24452
- emitOutput({ ok: true }, () => `Unlinked from ${link.projectKey}.`);
24453
- });
24454
- program3.command("status").description("Show what this repository is linked to.").action(async () => {
24455
- const repo = fetchRepo();
24456
- const link = repo ? fetchRepoLink(repo) : null;
24457
- const where = !repo ? "" : repo.worktree === repo.root ? repo.root : `${repo.worktree} (worktree of ${repo.root})`;
24458
- emitOutput(
24459
- { repo: repo?.root ?? null, worktree: repo?.worktree ?? null, link },
24460
- () => !repo ? "Not inside a git repository." : !link ? `${where}
24461
- not linked. Run: slop link <PROJECT_KEY>` : `${where}
24462
- linked to ${link.projectKey} ${link.projectName} (${link.baseUrl})`
24463
- );
24464
24331
  });
24465
24332
  }
24466
-
24467
- // src/commands/mcp.ts
24468
- import { execFileSync as execFileSync5, spawn as spawn3 } from "child_process";
24469
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync13, writeFileSync as writeFileSync8 } from "fs";
24470
- import "path";
24471
- import { homedir as homedir4 } from "os";
24472
- import { join as join12 } from "path";
24473
-
24474
- // src/platform/live.ts
24475
- var PING_EVERY_MS = 25e3;
24476
- var PONG_WITHIN_MS = 1e4;
24477
- var STANDBY_POLL_MS = 2e4;
24478
- async function waitForMessage(api, projectId, taskKey, options = {}) {
24479
- const ticket = await api.post(`/projects/${projectId}/tasks/${encodeURIComponent(taskKey)}/session/ticket`, ApiResponses.ConnectTicket, options.claim ?? {});
24480
- const socket = new WebSocket(`${api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
24481
- const outcome = await new Promise((resolve5) => {
24482
- const timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => resolve5({ kind: "timeout" }), options.timeoutMs) : null;
24483
- const finishWait = (result) => {
24484
- if (timer) clearTimeout(timer);
24485
- resolve5(result);
24333
+ function wakeCodexOverWs(text, threadId, endpoint) {
24334
+ return new Promise((resolve6) => {
24335
+ let socket;
24336
+ try {
24337
+ socket = new WebSocket(endpoint);
24338
+ } catch {
24339
+ resolve6(false);
24340
+ return;
24341
+ }
24342
+ let finished = false;
24343
+ let nextId = 1;
24344
+ const pending = /* @__PURE__ */ new Map();
24345
+ const finishWake = (ok) => {
24346
+ if (finished) return;
24347
+ finished = true;
24348
+ clearTimeout(timer);
24349
+ for (const reply of pending.values()) reply({ error: "connection ended" });
24350
+ pending.clear();
24351
+ try {
24352
+ socket.close();
24353
+ } catch {
24354
+ }
24355
+ resolve6(ok);
24486
24356
  };
24487
- socket.addEventListener("message", (event) => {
24488
- if (String(event.data) === "pong") return;
24489
- const push = JSON.parse(String(event.data));
24490
- if (push.type === "hello") {
24491
- api.sessionId = push.session.id;
24492
- persistRememberedSession(process.cwd(), push.session.id);
24493
- options.onHello?.(push.session);
24357
+ const timer = setTimeout(() => finishWake(false), 8e3);
24358
+ const callCodexMethod = (method, params) => new Promise((ok, fail) => {
24359
+ if (finished) {
24360
+ fail(new Error("connection ended"));
24494
24361
  return;
24495
24362
  }
24496
- if (push.type === "end") {
24497
- finishWait({ kind: "ended" });
24498
- return;
24363
+ const id = nextId++;
24364
+ pending.set(id, (message) => message.error ? fail(new Error(method)) : ok(message.result));
24365
+ socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
24366
+ });
24367
+ socket.addEventListener("error", () => finishWake(false));
24368
+ socket.addEventListener("close", () => finishWake(false));
24369
+ socket.addEventListener("message", (event) => {
24370
+ if (finished) return;
24371
+ try {
24372
+ const message = JSON.parse(String(event.data));
24373
+ if (typeof message.id === "number") {
24374
+ const reply = pending.get(message.id);
24375
+ pending.delete(message.id);
24376
+ reply?.(message);
24377
+ }
24378
+ } catch {
24499
24379
  }
24500
- if (push.type !== "thread") return;
24501
- if (options.onMessage?.({ thread: push.thread, message: push.message })) return;
24502
- finishWait({ kind: "message", push: { thread: push.thread, message: push.message } });
24503
24380
  });
24504
- socket.addEventListener("close", (event) => finishWait({ kind: "dropped", reason: event.reason || `code ${event.code}` }));
24505
- socket.addEventListener("error", () => finishWait({ kind: "dropped", reason: "socket error" }));
24506
- });
24507
- if (outcome.kind !== "message") persistRememberedSession(process.cwd(), null);
24508
- if (outcome.kind === "message" || outcome.kind === "timeout") {
24509
- socket.close(1e3, outcome.kind === "message" ? "handled" : "timeout");
24510
- await new Promise((resolve5) => {
24511
- socket.addEventListener("close", () => resolve5(), { once: true });
24512
- setTimeout(resolve5, 500);
24381
+ socket.addEventListener("open", () => {
24382
+ if (finished) return;
24383
+ void (async () => {
24384
+ try {
24385
+ await callCodexMethod("initialize", { clientInfo: { name: "slop", version: "0" }, capabilities: { experimentalApi: true } });
24386
+ socket.send(JSON.stringify({ jsonrpc: "2.0", method: "initialized", params: {} }));
24387
+ const loaded = await callCodexMethod("thread/loaded/list", {});
24388
+ if (!loaded.data.includes(threadId)) {
24389
+ finishWake(false);
24390
+ return;
24391
+ }
24392
+ await callCodexMethod("turn/start", { threadId, input: [{ type: "text", text }] });
24393
+ finishWake(true);
24394
+ } catch {
24395
+ finishWake(false);
24396
+ }
24397
+ })();
24513
24398
  });
24514
- }
24515
- return outcome;
24516
- }
24517
- var HeldSession = class {
24518
- constructor(api, projectId, taskKey, hooks) {
24519
- this.api = api;
24520
- this.projectId = projectId;
24521
- this.taskKey = taskKey;
24522
- this.hooks = hooks;
24523
- }
24524
- api;
24525
- projectId;
24526
- taskKey;
24527
- hooks;
24528
- socket = null;
24529
- stopped = false;
24530
- connecting = false;
24531
- timer = null;
24532
- heartbeat = null;
24533
- pongDue = null;
24534
- attempt = 0;
24535
- connected = 0;
24536
- session = null;
24537
- /**
24538
- * Whoever is waiting for a message. Handed each one, and answers whether it took it (a waiter for one thread lets
24539
- * the rest pass to the inbox); handed null when the agent is made unreachable under them.
24540
- */
24541
- waiters = /* @__PURE__ */ new Set();
24542
- greeted = null;
24543
- /**
24544
- * Settled once the room has said hello (the session id is known, so this process's next write names its session),
24545
- * or after a few seconds if the room is slow. A write before hello names no session, and a reply to it reaches
24546
- * nobody, so callers wait for this before their first write.
24547
- */
24548
- ready = new Promise((resolve5) => {
24549
- this.greeted = resolve5;
24550
- setTimeout(resolve5, 5e3).unref();
24551
24399
  });
24552
- async start() {
24553
- await this.connect();
24400
+ }
24401
+
24402
+ // src/platform/harness.ts
24403
+ var parent = null;
24404
+ function fetchParentCommand() {
24405
+ if (parent !== null) return parent;
24406
+ try {
24407
+ parent = process.platform === "win32" ? "" : execFileSync3("ps", ["-o", "command=", "-p", String(process.ppid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
24408
+ } catch {
24409
+ parent = "";
24554
24410
  }
24555
- async connect() {
24556
- if (this.stopped || this.connecting || this.socket) return;
24557
- this.connecting = true;
24558
- if (this.timer) clearTimeout(this.timer);
24411
+ return parent;
24412
+ }
24413
+ function parseHarnessFromCommand(command) {
24414
+ const first = basename2(command.trim().split(/\s+/)[0] ?? "");
24415
+ if (/^claude(\b|$)/.test(first)) return "claude";
24416
+ if (/^codex(\b|$)/.test(first)) return "codex";
24417
+ if (/(^|\/)codex(\S*)?\s/.test(command)) return "codex";
24418
+ return null;
24419
+ }
24420
+ function isNonInteractiveCommand(command) {
24421
+ return /(^|[\s/])codex(\S*)?\s+exec(\s|$)/.test(command);
24422
+ }
24423
+ function isNonInteractiveHarness() {
24424
+ return isNonInteractiveCommand(fetchParentCommand());
24425
+ }
24426
+ var isSamePath = (a, b) => {
24427
+ const resolveRealPath = (path2) => {
24559
24428
  try {
24560
- const ticket = await this.api.post(this.taskKey ? `/projects/${this.projectId}/tasks/${encodeURIComponent(this.taskKey)}/session/ticket` : `/projects/${this.projectId}/session/ticket`, ApiResponses.ConnectTicket, this.hooks.claim());
24561
- if (this.stopped) return;
24562
- const socket = new WebSocket(`${this.api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
24563
- this.socket = socket;
24564
- socket.addEventListener("open", () => {
24565
- if (this.stopped || this.socket !== socket) return;
24566
- this.attempt = 0;
24567
- this.startHeartbeat(socket);
24568
- });
24569
- socket.addEventListener("message", (event) => {
24570
- if (this.stopped || this.socket !== socket) return;
24571
- const data = String(event.data);
24572
- if (data === "pong") {
24573
- if (this.pongDue) clearTimeout(this.pongDue);
24574
- this.pongDue = null;
24575
- return;
24576
- }
24577
- const push = JSON.parse(data);
24578
- if (push.type === "hello") {
24579
- this.api.sessionId = push.session.id;
24580
- this.session = push.session;
24581
- this.taskKey = push.session.taskKey;
24582
- persistRememberedSession(process.cwd(), push.session.id);
24583
- this.greeted?.();
24584
- this.greeted = null;
24585
- this.connected += 1;
24586
- if (this.connected > 1) void this.hooks.onReconnect?.(push.session);
24587
- return;
24588
- }
24589
- if (push.type === "end") {
24590
- this.stopped = true;
24591
- return;
24592
- }
24593
- if (push.type === "reachable") {
24594
- if (this.session) this.session = { ...this.session, reachable: push.on };
24595
- if (!push.on) for (const waiter of [...this.waiters]) waiter(null);
24596
- this.hooks.onReachable?.(push.on);
24597
- return;
24598
- }
24599
- if (push.type !== "thread") return;
24600
- const delivered = { thread: push.thread, message: push.message };
24601
- let taken = false;
24602
- for (const waiter of [...this.waiters]) if (waiter(delivered)) taken = true;
24603
- if (!taken) this.hooks.onMessage(delivered);
24604
- });
24605
- socket.addEventListener("close", (event) => {
24606
- if (this.socket !== socket) return;
24607
- this.stopHeartbeat();
24608
- if (this.socket === socket) this.socket = null;
24609
- if (this.stopped) return;
24610
- if (event.reason === "replaced") this.standBy();
24611
- else this.schedule();
24612
- });
24613
- socket.addEventListener("error", () => {
24614
- });
24429
+ return realpathSync3(path2);
24615
24430
  } catch {
24616
- this.schedule();
24617
- } finally {
24618
- this.connecting = false;
24431
+ return path2;
24619
24432
  }
24620
- }
24621
- /** A ping every 25s; no pong within 10s means the socket is dead however open it looks. Close it and come back. */
24622
- startHeartbeat(socket) {
24623
- this.stopHeartbeat();
24624
- this.heartbeat = setInterval(() => {
24625
- if (socket.readyState !== WebSocket.OPEN) return;
24626
- socket.send("ping");
24627
- if (!this.pongDue) {
24628
- this.pongDue = setTimeout(() => {
24629
- this.pongDue = null;
24630
- socket.close(4e3, "no pong");
24631
- }, PONG_WITHIN_MS);
24632
- }
24633
- }, PING_EVERY_MS);
24634
- }
24635
- stopHeartbeat() {
24636
- if (this.heartbeat) clearInterval(this.heartbeat);
24637
- if (this.pongDue) clearTimeout(this.pongDue);
24638
- this.heartbeat = null;
24639
- this.pongDue = null;
24640
- }
24641
- schedule() {
24642
- if (this.stopped) return;
24643
- if (this.timer) clearTimeout(this.timer);
24644
- this.attempt += 1;
24645
- this.timer = setTimeout(() => void this.connect(), Math.min(3e4, 1e3 * 2 ** Math.min(this.attempt, 5)));
24646
- }
24647
- /** Another process holds this session now: look now and then, and take it back once it is no longer live. */
24648
- standBy() {
24649
- if (this.stopped) return;
24650
- if (this.timer) clearTimeout(this.timer);
24651
- this.timer = setTimeout(async () => {
24652
- if (this.stopped) return;
24653
- let holder = true;
24654
- try {
24655
- const sessions = await this.api.get(`/projects/${this.projectId}/sessions`, ApiResponses.Sessions);
24656
- holder = sessions.some((session) => session.id === this.session?.id && session.state === "live");
24657
- } catch {
24658
- }
24659
- if (holder) this.standBy();
24660
- else void this.connect();
24661
- }, STANDBY_POLL_MS);
24662
- }
24663
- stop() {
24664
- this.stopped = true;
24665
- if (this.api.sessionId === this.session?.id) this.api.sessionId = null;
24666
- if (this.timer) clearTimeout(this.timer);
24667
- this.stopHeartbeat();
24668
- this.socket?.close(1e3, "released");
24669
- }
24433
+ };
24434
+ return resolveRealPath(a) === resolveRealPath(b);
24670
24435
  };
24671
-
24672
- // src/platform/agent-identity.ts
24673
- import { randomUUID as randomUUID4 } from "crypto";
24674
- import { basename as basename3, join as join9, resolve as resolve3 } from "path";
24675
-
24676
- // src/platform/harness.ts
24677
- import { execFileSync as execFileSync4 } from "child_process";
24678
- import { closeSync, openSync, readdirSync as readdirSync2, readSync, readlinkSync, realpathSync as realpathSync2, statSync as statSync3 } from "fs";
24679
- import { basename as basename2, join as join8 } from "path";
24680
-
24681
- // src/platform/harness-wake.ts
24682
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
24683
- import { join as join7 } from "path";
24684
- import { connect } from "net";
24685
- import { spawn as spawn2 } from "child_process";
24686
- import { randomUUID as randomUUID3 } from "crypto";
24687
-
24688
- // src/platform/homes.ts
24689
- import { execFileSync as execFileSync3 } from "child_process";
24690
- import { existsSync as existsSync6, readdirSync, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
24691
- import { homedir as homedir3 } from "os";
24692
- import { join as join6, resolve as resolve2 } from "path";
24693
- var deduplicateStrings = (paths) => [...new Set(paths.map((path2) => resolve2(path2)))];
24694
- function fetchAncestorEnvironments() {
24695
- if (process.platform === "win32") return [];
24696
- const seen = [];
24697
- try {
24698
- if (process.platform === "linux") {
24699
- let pid2 = process.ppid;
24700
- for (let hop = 0; hop < 6 && pid2 > 1; hop += 1) {
24701
- seen.push(readFileSync6(`/proc/${pid2}/environ`, "latin1").replace(/\0/g, " "));
24702
- const stat = readFileSync6(`/proc/${pid2}/stat`, "utf8");
24703
- pid2 = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
24436
+ function isMainChatRollout(sessionMetaLine) {
24437
+ if (!sessionMetaLine.includes('"session_meta"')) return false;
24438
+ if (/"parent_thread_id"\s*:\s*"/.test(sessionMetaLine)) return false;
24439
+ const source = /"source"\s*:\s*("([^"]*)"|\{)/.exec(sessionMetaLine);
24440
+ if (!source) return true;
24441
+ if (source[1] === "{") return false;
24442
+ return source[2] !== "exec";
24443
+ }
24444
+ function fetchCodexConversationByCwd(roots, cwd) {
24445
+ const rollouts = [];
24446
+ const findRolloutsInDirectory = (dir, depth) => {
24447
+ let entries;
24448
+ try {
24449
+ entries = readdirSync3(dir, { withFileTypes: true });
24450
+ } catch {
24451
+ return;
24452
+ }
24453
+ for (const entry of entries) {
24454
+ const path2 = join7(dir, entry.name);
24455
+ if (entry.isDirectory()) {
24456
+ if (depth < 3) findRolloutsInDirectory(path2, depth + 1);
24457
+ continue;
24704
24458
  }
24705
- for (const entry of readdirSync("/proc")) {
24706
- if (!/^\d+$/.test(entry)) continue;
24707
- try {
24708
- seen.push(readFileSync6(`/proc/${entry}/environ`, "latin1").replace(/\0/g, " "));
24709
- } catch {
24710
- }
24459
+ const match = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(entry.name);
24460
+ if (!match) continue;
24461
+ try {
24462
+ rollouts.push({ path: path2, id: match[1] ?? "", mtime: statSync3(path2).mtimeMs });
24463
+ } catch {
24711
24464
  }
24712
- return seen;
24713
24465
  }
24714
- const runPs = (args) => execFileSync3("ps", args, { encoding: "utf8", maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "ignore"] });
24715
- let pid = process.ppid;
24716
- for (let hop = 0; hop < 6 && pid > 1; hop += 1) {
24717
- seen.push(runPs(["eww", "-o", "command=", "-p", String(pid)]));
24718
- pid = Number(runPs(["-o", "ppid=", "-p", String(pid)]).trim());
24466
+ };
24467
+ for (const root of roots) findRolloutsInDirectory(root, 0);
24468
+ rollouts.sort((a, b) => b.mtime - a.mtime);
24469
+ const matching = /* @__PURE__ */ new Set();
24470
+ for (const rollout of rollouts) {
24471
+ let head = "";
24472
+ try {
24473
+ const fd = openSync(rollout.path, "r");
24474
+ try {
24475
+ const buffer = Buffer.alloc(64 * 1024);
24476
+ head = buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8");
24477
+ } finally {
24478
+ closeSync(fd);
24479
+ }
24480
+ } catch {
24481
+ continue;
24719
24482
  }
24720
- seen.push(runPs(["axeww", "-o", "command="]));
24721
- } catch {
24483
+ const line = head.split("\n")[0] ?? "";
24484
+ if (!line.includes('"session_meta"')) continue;
24485
+ if (!isMainChatRollout(line)) continue;
24486
+ const found = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(line);
24487
+ if (!found) continue;
24488
+ let dir;
24489
+ try {
24490
+ dir = JSON.parse(`"${found[1]}"`);
24491
+ } catch {
24492
+ continue;
24493
+ }
24494
+ if (isSamePath(dir, cwd)) matching.add(rollout.id);
24495
+ if (matching.size > 1) return null;
24722
24496
  }
24723
- return seen;
24724
- }
24725
- var environments = null;
24726
- function findHomesInUse(variable) {
24727
- environments ??= fetchAncestorEnvironments();
24728
- const found = [];
24729
- const pattern = new RegExp(`(?:^|\\s)${variable}=(\\S+)`, "g");
24730
- for (const text of environments) for (const match of text.matchAll(pattern)) found.push(match[1] ?? "");
24731
- return found.filter(Boolean);
24497
+ return matching.size === 1 ? [...matching][0] : null;
24732
24498
  }
24733
- var findDirectoriesNamed = (root, prefix) => {
24499
+ function isMainChatThread(roots, threadId) {
24500
+ const path2 = fetchCodexRolloutPath(roots, threadId);
24501
+ if (!path2) return true;
24734
24502
  try {
24735
- return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.name.startsWith(prefix) && (entry.isDirectory() || entry.isSymbolicLink())).map((entry) => join6(root, entry.name)).filter((path2) => {
24736
- try {
24737
- return statSync2(path2).isDirectory();
24738
- } catch {
24739
- return false;
24740
- }
24741
- });
24503
+ const fd = openSync(path2, "r");
24504
+ try {
24505
+ const buffer = Buffer.alloc(64 * 1024);
24506
+ const head = buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8");
24507
+ return isMainChatRollout(head.split("\n")[0] ?? "");
24508
+ } finally {
24509
+ closeSync(fd);
24510
+ }
24742
24511
  } catch {
24743
- return [];
24512
+ return true;
24744
24513
  }
24745
- };
24746
- var hasAnyEntry = (dir, files) => files.some((file2) => existsSync6(join6(dir, file2)));
24747
- function fetchClaudeHomes(root = homedir3(), inUse = findHomesInUse) {
24748
- if (process.env.SLOP_CLAUDE_HOME) return [process.env.SLOP_CLAUDE_HOME];
24749
- const named = process.env.CLAUDE_CONFIG_DIR ? [process.env.CLAUDE_CONFIG_DIR] : [];
24750
- return deduplicateStrings([join6(root, ".claude"), ...named, ...inUse("CLAUDE_CONFIG_DIR"), ...findDirectoriesNamed(root, ".claude").filter((dir) => hasAnyEntry(dir, ["settings.json", ".claude.json", "sessions"]))]);
24751
24514
  }
24752
- function fetchCodexHomes(root = homedir3(), inUse = findHomesInUse) {
24753
- if (process.env.SLOP_CODEX_HOME) return [process.env.SLOP_CODEX_HOME];
24754
- const named = process.env.CODEX_HOME ? [process.env.CODEX_HOME] : [];
24755
- return deduplicateStrings([join6(root, ".codex"), ...named, ...inUse("CODEX_HOME"), ...findDirectoriesNamed(root, ".codex").filter((dir) => hasAnyEntry(dir, ["config.toml", "auth.json", "sessions"]))]);
24515
+ function fetchCodexRolloutPath(roots, threadId) {
24516
+ const suffix = `-${threadId}.jsonl`;
24517
+ let best = null;
24518
+ const findRolloutInDirectory = (dir, depth) => {
24519
+ let entries;
24520
+ try {
24521
+ entries = readdirSync3(dir, { withFileTypes: true });
24522
+ } catch {
24523
+ return;
24524
+ }
24525
+ for (const entry of entries) {
24526
+ const path2 = join7(dir, entry.name);
24527
+ if (entry.isDirectory()) {
24528
+ if (depth < 3) findRolloutInDirectory(path2, depth + 1);
24529
+ continue;
24530
+ }
24531
+ if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(suffix)) continue;
24532
+ try {
24533
+ const mtime = statSync3(path2).mtimeMs;
24534
+ if (!best || mtime > best.mtime) best = { path: path2, mtime };
24535
+ } catch {
24536
+ }
24537
+ }
24538
+ };
24539
+ for (const root of roots) findRolloutInDirectory(root, 0);
24540
+ return best ? best.path : null;
24756
24541
  }
24757
- function resolveOwnHarnessHome(variable) {
24758
- if (process.platform === "win32") return null;
24542
+ function resolveCodexBinary() {
24759
24543
  try {
24760
- let pid = process.ppid;
24761
- for (let hop = 0; hop < 6 && pid > 1; hop += 1) {
24762
- const text = process.platform === "linux" ? readFileSync6(`/proc/${pid}/environ`, "latin1").replace(/\0/g, " ") : execFileSync3("ps", ["eww", "-o", "command=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
24763
- const match = new RegExp(`(?:^|\\s)${variable}=(\\S+)`).exec(text);
24764
- if (match?.[1]) return resolve2(match[1]);
24765
- if (process.platform === "linux") {
24766
- const stat = readFileSync6(`/proc/${pid}/stat`, "utf8");
24767
- pid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
24768
- } else pid = Number(execFileSync3("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim());
24769
- }
24544
+ execFileSync3(process.platform === "win32" ? "where" : "which", ["codex"], { stdio: "ignore" });
24545
+ return "codex";
24770
24546
  } catch {
24771
24547
  }
24772
- return null;
24773
- }
24774
-
24775
- // src/platform/harness-wake.ts
24776
- function fetchHarnessRegistration(pid = process.ppid) {
24777
- for (const home of fetchClaudeHomes()) {
24778
- const path2 = join7(home, "sessions", `${pid}.json`);
24779
- if (!existsSync7(path2)) continue;
24548
+ if (parseHarnessFromCommand(fetchParentCommand()) === "codex" && process.platform !== "win32") {
24780
24549
  try {
24781
- return JSON.parse(readFileSync7(path2, "utf8"));
24550
+ const exe = process.platform === "linux" ? readlinkSync(`/proc/${process.ppid}/exe`) : execFileSync3("ps", ["-o", "comm=", "-p", String(process.ppid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
24551
+ if (exe && basename2(exe).startsWith("codex")) return exe;
24782
24552
  } catch {
24783
- return null;
24784
24553
  }
24785
24554
  }
24555
+ return "codex";
24556
+ }
24557
+ function fetchHarnessConversation(cwd) {
24558
+ const explicit = process.env.SLOP_CONVERSATION_ID?.trim();
24559
+ if (explicit) return { id: explicit, harness: process.env.SLOP_HARNESS === "codex" ? "codex" : process.env.SLOP_HARNESS === "claude" ? "claude" : "other" };
24560
+ if (process.env.CODEX_THREAD_ID?.trim()) return { id: process.env.CODEX_THREAD_ID.trim(), harness: "codex" };
24561
+ if (process.env.CLAUDE_CODE_SESSION_ID?.trim()) return { id: process.env.CLAUDE_CODE_SESSION_ID.trim(), harness: "claude" };
24562
+ const harness = parseHarnessFromCommand(fetchParentCommand());
24563
+ if (harness === "claude") {
24564
+ const id = resolveClaudeSession()?.sessionId;
24565
+ return id ? { id, harness } : null;
24566
+ }
24567
+ if (harness === "codex" && !isNonInteractiveHarness()) {
24568
+ const id = fetchCodexConversationByCwd(fetchCodexHomes().map((home) => join7(home, "sessions")), cwd);
24569
+ return id ? { id, harness } : null;
24570
+ }
24786
24571
  return null;
24787
24572
  }
24788
- function wakeHarness(text, pid = Number(process.env.SLOP_HARNESS_PID) || process.ppid) {
24789
- const registration = fetchHarnessRegistration(pid);
24790
- if (!registration?.sessionId || !registration.messagingSocketPath) return Promise.resolve(false);
24791
- const frame = {
24792
- uuid: randomUUID3(),
24793
- session_id: registration.sessionId,
24794
- type: "user",
24795
- parent_tool_use_id: null,
24796
- message: { role: "user", content: text }
24573
+
24574
+ // src/platform/agent-identity.ts
24575
+ var processConversation = randomUUID4();
24576
+ var startedAt = Date.now();
24577
+ function fetchKnownConversation(cwd) {
24578
+ const own2 = fetchHarnessConversation(cwd);
24579
+ if (own2) return own2;
24580
+ const remembered = fetchRememberedConversation(cwd, startedAt - 12e4);
24581
+ if (remembered?.harness === "codex" && isMainChatThread(fetchCodexHomes().map((home) => join8(home, "sessions")), remembered.id)) return remembered;
24582
+ return remembered?.harness === "codex" ? null : remembered;
24583
+ }
24584
+ function resolveAgentIdentity(cwd, fallback = "process") {
24585
+ const conversation = fetchKnownConversation(cwd);
24586
+ const repo = fetchRepo();
24587
+ return {
24588
+ conversation: conversation?.id ?? (fallback === "process" ? processConversation : `dir:${resolve4(cwd)}`),
24589
+ harness: conversation?.harness ?? null,
24590
+ directory: repo ? basename3(repo.worktree) : basename3(cwd),
24591
+ branch: fetchCurrentBranch(cwd)
24797
24592
  };
24798
- return new Promise((resolve5) => {
24799
- const socket = connect({ path: registration.messagingSocketPath });
24800
- const finishWake = (ok) => {
24801
- clearTimeout(timer);
24802
- socket.destroy();
24803
- resolve5(ok);
24804
- };
24805
- const timer = setTimeout(() => finishWake(false), 3e3);
24806
- socket.on("connect", () => {
24807
- socket.write(`${JSON.stringify(frame)}
24808
- `, (error62) => error62 ? finishWake(false) : setTimeout(() => finishWake(true), 200));
24809
- });
24810
- socket.on("error", () => finishWake(false));
24811
- });
24812
24593
  }
24813
- function wakeCodex(text, threadId, options = {}) {
24814
- const endpoint = process.env.SLOP_CODEX_APP_SERVER;
24815
- if (endpoint) return wakeCodexOverWs(text, threadId, endpoint);
24816
- return new Promise((resolve5) => {
24817
- let child;
24594
+
24595
+ // src/platform/api.ts
24596
+ function buildApiClient(options) {
24597
+ async function sendApiRequest(method, path2, schema, body2) {
24818
24598
  try {
24819
- child = spawn2(options.binary ?? "codex", ["queue", "--thread", threadId, "--message", text], {
24820
- env: options.codexHome ? { ...process.env, CODEX_HOME: options.codexHome } : process.env,
24821
- stdio: ["ignore", "ignore", "ignore"]
24822
- });
24599
+ assertApiResponseContract(method, path2, schema);
24823
24600
  } catch {
24824
- resolve5(false);
24825
- return;
24601
+ throw new CliError("This request does not match the API contract", "Update slop to the latest version", "internal");
24826
24602
  }
24827
- const timer = setTimeout(() => {
24828
- child.kill();
24829
- resolve5(false);
24830
- }, 15e3);
24831
- child.on("error", () => {
24832
- clearTimeout(timer);
24833
- resolve5(false);
24834
- });
24835
- child.on("exit", (code) => {
24836
- clearTimeout(timer);
24837
- resolve5(code === 0);
24838
- });
24839
- });
24840
- }
24841
- function wakeCodexOverWs(text, threadId, endpoint) {
24842
- return new Promise((resolve5) => {
24843
- let socket;
24603
+ const headers = { accept: "application/json" };
24604
+ if (body2 !== void 0) headers["content-type"] = "application/json";
24605
+ if (options.token) headers.authorization = `Bearer ${options.token}`;
24606
+ if (client.sessionId) headers["x-slop-session"] = client.sessionId;
24607
+ let response;
24844
24608
  try {
24845
- socket = new WebSocket(endpoint);
24846
- } catch {
24847
- resolve5(false);
24848
- return;
24609
+ response = await fetch(`${options.baseUrl}/api${path2}`, {
24610
+ method,
24611
+ headers,
24612
+ body: body2 === void 0 ? void 0 : JSON.stringify(body2),
24613
+ signal: AbortSignal.timeout(2e4)
24614
+ });
24615
+ } catch (error62) {
24616
+ const reason = error62 instanceof Error && error62.name === "TimeoutError" ? "timed out" : "is unreachable";
24617
+ throw new CliError(`${options.baseUrl} ${reason}`, void 0, "network");
24849
24618
  }
24850
- let nextId = 1;
24851
- const pending = /* @__PURE__ */ new Map();
24852
- const finishWake = (ok) => {
24853
- clearTimeout(timer);
24619
+ const text = await response.text();
24620
+ if (response.ok) {
24854
24621
  try {
24855
- socket.close();
24622
+ return schema.parse(JSON.parse(text));
24856
24623
  } catch {
24624
+ throw new CliError("The server returned an incompatible response", "Update slop to the latest version", "internal");
24857
24625
  }
24858
- resolve5(ok);
24859
- };
24860
- const timer = setTimeout(() => finishWake(false), 8e3);
24861
- const call = (method, params) => new Promise((ok, fail) => {
24862
- const id = nextId++;
24863
- pending.set(id, (message) => message.error ? fail(new Error(method)) : ok(message.result));
24864
- socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
24865
- });
24866
- socket.addEventListener("error", () => finishWake(false));
24867
- socket.addEventListener("message", (event) => {
24868
- try {
24869
- const message = JSON.parse(String(event.data));
24870
- if (typeof message.id === "number") pending.get(message.id)?.(message);
24871
- } catch {
24626
+ }
24627
+ let parsed = null;
24628
+ try {
24629
+ parsed = ApiErrorBody.safeParse(JSON.parse(text));
24630
+ } catch {
24631
+ }
24632
+ if (parsed?.success) {
24633
+ const { code, message } = parsed.data.error;
24634
+ throw new CliError(message, code === "unauthenticated" ? "slop login" : void 0, code);
24635
+ }
24636
+ throw new CliError(`${method} ${path2} failed with HTTP ${response.status}`, void 0, "http");
24637
+ }
24638
+ const client = {
24639
+ baseUrl: options.baseUrl,
24640
+ sessionId: null,
24641
+ get: (path2, schema) => sendApiRequest("GET", path2, schema),
24642
+ post: (path2, schema, body2) => sendApiRequest("POST", path2, schema, body2 ?? {}),
24643
+ patch: (path2, schema, body2) => sendApiRequest("PATCH", path2, schema, body2),
24644
+ put: (path2, schema, body2) => sendApiRequest("PUT", path2, schema, body2),
24645
+ delete: (path2, schema) => sendApiRequest("DELETE", path2, schema)
24646
+ };
24647
+ return client;
24648
+ }
24649
+ function buildAuthedApiClient() {
24650
+ const session = resolveSession();
24651
+ if (!session.token) throw new CliError(`Not signed in to ${session.baseUrl}`, "slop login", "unauthenticated");
24652
+ const client = buildApiClient(session);
24653
+ const conversation = fetchKnownConversation(process.cwd());
24654
+ client.sessionId = fetchRememberedSession(process.cwd(), conversation?.id ?? `dir:${resolve5(process.cwd())}`);
24655
+ return client;
24656
+ }
24657
+
24658
+ // src/platform/machine.ts
24659
+ import { execFileSync as execFileSync4 } from "child_process";
24660
+ import { createHash as createHash4, randomUUID as randomUUID5 } from "crypto";
24661
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
24662
+ import { hostname as hostname3, platform, userInfo } from "os";
24663
+ import { dirname as dirname4, join as join9 } from "path";
24664
+ function runCommand(command, args) {
24665
+ try {
24666
+ return execFileSync4(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
24667
+ } catch {
24668
+ return null;
24669
+ }
24670
+ }
24671
+ function fetchOsMachineId() {
24672
+ switch (platform()) {
24673
+ case "darwin":
24674
+ return /"IOPlatformUUID" = "([^"]+)"/.exec(runCommand("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]) ?? "")?.[1] ?? null;
24675
+ case "linux":
24676
+ for (const path2 of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
24677
+ if (existsSync6(path2)) return readFileSync7(path2, "utf8").trim() || null;
24872
24678
  }
24679
+ return null;
24680
+ case "win32":
24681
+ return /MachineGuid\s+REG_SZ\s+(\S+)/.exec(
24682
+ runCommand("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]) ?? ""
24683
+ )?.[1] ?? null;
24684
+ default:
24685
+ return null;
24686
+ }
24687
+ }
24688
+ function resolvePersistedMachineId() {
24689
+ const path2 = join9(dirname4(buildConfigPath()), "machine-id");
24690
+ if (existsSync6(path2)) return readFileSync7(path2, "utf8").trim();
24691
+ const id = randomUUID5();
24692
+ mkdirSync4(dirname4(path2), { recursive: true, mode: 448 });
24693
+ writeFileSync5(path2, `${id}
24694
+ `, { mode: 384 });
24695
+ return id;
24696
+ }
24697
+ function buildMachineInfo() {
24698
+ const raw = `${fetchOsMachineId() ?? resolvePersistedMachineId()}:${userInfo().username}`;
24699
+ return {
24700
+ fingerprint: createHash4("sha256").update(raw).digest("hex"),
24701
+ name: hostname3().replace(/\.local$/, ""),
24702
+ platform: platform()
24703
+ };
24704
+ }
24705
+
24706
+ // src/platform/output.ts
24707
+ var jsonMode = false;
24708
+ var updateJsonMode = (on) => {
24709
+ jsonMode = on;
24710
+ };
24711
+ var isJsonMode = () => jsonMode;
24712
+ function emitOutput(value, human) {
24713
+ process.stdout.write(jsonMode ? `${JSON.stringify(value, null, 2)}
24714
+ ` : `${human()}
24715
+ `);
24716
+ }
24717
+ function exitWithError(error62) {
24718
+ const cli = error62 instanceof CliError ? error62 : null;
24719
+ const message = error62 instanceof Error ? error62.message : String(error62);
24720
+ if (jsonMode) {
24721
+ process.stderr.write(`${JSON.stringify({ error: { code: cli?.code ?? "error", message, hint: cli?.hint } })}
24722
+ `);
24723
+ } else {
24724
+ process.stderr.write(`error: ${message}
24725
+ `);
24726
+ if (cli?.hint) process.stderr.write(` try: ${cli.hint}
24727
+ `);
24728
+ }
24729
+ process.exit(cli?.exitCode ?? 1);
24730
+ }
24731
+ function formatTable(rows) {
24732
+ const widths = [];
24733
+ for (const row of rows) row.forEach((cell, index) => widths[index] = Math.max(widths[index] ?? 0, cell.length));
24734
+ return rows.map((row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index])).join(" ")).join("\n");
24735
+ }
24736
+ var formatStatusLabel = (statuses, key) => statuses.find((status) => status.key === key)?.label ?? key;
24737
+ var formatTaskLine = (statuses) => (task) => [
24738
+ task.key,
24739
+ formatStatusLabel(statuses, task.status),
24740
+ TASK_SEVERITY_LABELS[task.severity],
24741
+ task.assignee?.name ?? "-",
24742
+ task.title
24743
+ ];
24744
+ function formatTaskDetail(task, url2, statuses) {
24745
+ const lines = [
24746
+ `${task.key} ${task.title}`,
24747
+ `status: ${formatStatusLabel(statuses, task.status)} severity: ${TASK_SEVERITY_LABELS[task.severity]} assignee: ${task.assignee?.name ?? "nobody"} by: ${task.createdBy.name}`,
24748
+ url2
24749
+ ];
24750
+ if (task.links.length > 0) lines.push(`links: ${task.links.map((link) => `${link.title} <${link.url}>`).join(", ")}`);
24751
+ if (task.archivedAt) lines.push("archived");
24752
+ if (task.items.length > 0) {
24753
+ lines.push("", `plan ${task.progress.done}/${task.progress.total} done`);
24754
+ task.items.forEach((item, index) => lines.push(` ${index + 1}. [${item.done ? "x" : " "}] ${item.title}${item.note ? ` (${item.note.split("\n")[0]})` : ""}`));
24755
+ }
24756
+ if (task.description) lines.push("", task.description);
24757
+ if (task.notes) lines.push("", "notes:", task.notes);
24758
+ return lines.join("\n");
24759
+ }
24760
+
24761
+ // src/commands/auth.ts
24762
+ function openBrowser(url2) {
24763
+ const [command, args] = platform2() === "darwin" ? ["open", [url2]] : platform2() === "win32" ? ["cmd", ["/c", "start", "", url2]] : ["xdg-open", [url2]];
24764
+ try {
24765
+ spawn2(command, args, { stdio: "ignore", detached: true }).on("error", () => {
24766
+ }).unref();
24767
+ } catch {
24768
+ }
24769
+ }
24770
+ var waitForMilliseconds = (ms) => new Promise((done) => setTimeout(done, ms));
24771
+ var isHeadless = () => Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY) || platform2() === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
24772
+ function registerAuthCommands(program3) {
24773
+ program3.command("login").description("Sign this machine in. Opens the web app, where you approve the login. On a VM or over SSH, open the printed link on any device.").option("--url <url>", "the Slopware Tasks instance to sign in to").option("--no-browser", "print the link instead of opening it").action(async (options) => {
24774
+ const baseUrl = (options.url || process.env.SLOP_URL || fetchConfig()?.baseUrl || DEFAULT_URL).replace(/\/+$/, "");
24775
+ const api = buildApiClient({ baseUrl, token: null });
24776
+ const started = await api.post("/cli/auth/start", ApiResponses.StartCliAuthResult, { machine: buildMachineInfo() });
24777
+ const remote = isHeadless();
24778
+ process.stderr.write(`
24779
+ Approve this login in a browser${remote ? " on any device (this machine has no browser; your laptop or phone is fine)" : ""}:
24780
+ ${started.verifyUrl}
24781
+
24782
+ Confirm the code matches: ${started.userCode}
24783
+
24784
+ `);
24785
+ if (options.browser && !remote) openBrowser(started.verifyUrl);
24786
+ const deadline = Date.now() + started.expiresIn * 1e3;
24787
+ while (Date.now() < deadline) {
24788
+ await waitForMilliseconds(started.interval * 1e3);
24789
+ const poll = await api.post("/cli/auth/poll", ApiResponses.PollCliAuthResult, { deviceCode: started.deviceCode });
24790
+ if (poll.status === "pending") continue;
24791
+ if (poll.status === "denied") throw new CliError("The login was denied in the web app");
24792
+ if (poll.status === "expired") break;
24793
+ const me = await buildApiClient({ baseUrl, token: poll.token }).get("/me", ApiResponses.Me);
24794
+ persistConfig({ baseUrl, token: poll.token, email: me.user.email });
24795
+ const skills = installSkills();
24796
+ emitOutput({ ok: true, user: me.user, machine: me.machine, baseUrl, skills }, () => `Signed in as ${me.user.email} on ${me.machine?.name ?? "this machine"}.${skills.length ? `
24797
+ Agent skills installed in ~/.claude/skills and ~/.agents/skills. Next: slop mcp install (the board as tools for Claude Code and Codex).` : ""}`);
24798
+ return;
24799
+ }
24800
+ throw new CliError("The login request expired before it was approved", "slop login");
24801
+ });
24802
+ program3.command("logout").description("Sign this machine out and revoke its token.").action(async () => {
24803
+ const session = resolveSession();
24804
+ if (session.token) await buildApiClient(session).post("/cli/auth/logout", ApiResponses.Ok).catch(() => {
24873
24805
  });
24874
- socket.addEventListener("open", () => {
24875
- void (async () => {
24876
- try {
24877
- await call("initialize", { clientInfo: { name: "slop", version: "0" }, capabilities: { experimentalApi: true } });
24878
- socket.send(JSON.stringify({ jsonrpc: "2.0", method: "initialized", params: {} }));
24879
- const loaded = await call("thread/loaded/list", {});
24880
- if (!loaded.data.includes(threadId)) {
24881
- finishWake(false);
24882
- return;
24883
- }
24884
- await call("turn/start", { threadId, input: [{ type: "text", text }] });
24885
- finishWake(true);
24886
- } catch {
24887
- finishWake(false);
24888
- }
24889
- })();
24806
+ deleteConfig();
24807
+ emitOutput({ ok: true }, () => "Signed out.");
24808
+ });
24809
+ program3.command("whoami").description("Show the signed-in user, machine and instance.").action(async () => {
24810
+ const api = buildAuthedApiClient();
24811
+ const me = await api.get("/me", ApiResponses.Me);
24812
+ emitOutput({ ...me, baseUrl: api.baseUrl }, () => `${me.user.name} <${me.user.email}>
24813
+ machine: ${me.machine?.name ?? "-"}
24814
+ instance: ${api.baseUrl}`);
24815
+ });
24816
+ }
24817
+
24818
+ // src/commands/guide.ts
24819
+ var AGENT_GUIDE = `## Task tracking
24820
+
24821
+ This repository is linked to a Slopware Tasks board. Use the \`slop\` CLI to keep it current.
24822
+
24823
+ - When you find work that should be tracked (a bug, a follow-up, a TODO you are not doing now), create a task:
24824
+ \`slop task create "Short imperative title" -d "What, where, why" -s <low|medium|high|critical>\`
24825
+ - Before starting tracked work: \`slop task list\`, then \`slop task start <KEY>\`.
24826
+ - When it is finished and verified: \`slop task done <KEY>\`.
24827
+ - 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 -\`.
24828
+ - 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. If a wake says mail is waiting, use \`session check\` with the project it names. Each conversation and project has its own inbox. Messages for you reach you on their own: idle, as a turn of yours; while you work, as your next turn (Codex) or with your next tool result (Claude Code). Messages are letters, not chat: the other agent reads yours when it gets to it, its reply may predate your latest, so write once and completely, answer a pile of messages with one reply, and reply only when it changes something (never to acknowledge). Do not wait for replies; end your turn. 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\`.
24829
+ - 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.
24830
+ - 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.
24831
+ - Add \`--json\` to any command for machine-readable output. \`slop task --help\` lists everything.
24832
+ - When you open a pull request for a task, link it: \`slop task link <KEY> <pr-url>\`.
24833
+ - Columns are the project's own: \`slop statuses\` lists them; \`slop task move <KEY> "<column>"\` takes a key or label. \`slop task start\` / \`slop task done\` go to the first "in progress" / "done" column. Finished work is archived from the board (\`slop task archive <KEY>\`), not deleted. Do not delete tasks you did not create.
24834
+ `;
24835
+ function registerGuideCommand(program3) {
24836
+ program3.command("guide").description("Print a snippet for AGENTS.md / CLAUDE.md that teaches an agent to use this CLI.").action(() => emitOutput({ markdown: AGENT_GUIDE }, () => AGENT_GUIDE));
24837
+ }
24838
+
24839
+ // src/commands/projects.ts
24840
+ async function fetchProjectByKey(api, key) {
24841
+ const projects = await api.get("/projects", ApiResponses.Projects);
24842
+ const matches = projects.filter((project) => project.key === key.toUpperCase() || project.id === key);
24843
+ if (matches.length === 1) return matches[0];
24844
+ if (matches.length > 1) {
24845
+ throw new CliError(`More than one of your organizations has a project with the key ${key.toUpperCase()}:
24846
+ ${matches.map((project) => ` ${project.id} ${project.name}`).join("\n")}`, "slop link <project id>");
24847
+ }
24848
+ throw new CliError(`No project with the key ${key.toUpperCase()}`, "slop projects");
24849
+ }
24850
+ async function resolveProject(api, explicit) {
24851
+ if (explicit) return fetchProjectByKey(api, explicit);
24852
+ const repo = fetchRepo();
24853
+ const link = repo ? fetchRepoLink(repo) : null;
24854
+ if (!link) throw new CliError("This repository is not linked to a project", "slop link <PROJECT_KEY> (or pass --project KEY)", "not_linked");
24855
+ if (link.baseUrl !== api.baseUrl) {
24856
+ throw new CliError(`This repository is linked to ${link.baseUrl}, but you are signed in to ${api.baseUrl}`, `slop login --url ${link.baseUrl}`);
24857
+ }
24858
+ return { id: link.projectId, key: link.projectKey };
24859
+ }
24860
+ async function listMembers(api, projectId) {
24861
+ const project = await api.get(`/projects/${projectId}`, ApiResponses.Project);
24862
+ return (await api.get(`/orgs/${project.orgId}/members`, ApiResponses.MembersResult)).members;
24863
+ }
24864
+ function registerProjectCommands(program3) {
24865
+ program3.command("projects").description("List the projects you can reach.").action(async () => {
24866
+ const projects = await buildAuthedApiClient().get("/projects", ApiResponses.Projects);
24867
+ emitOutput(
24868
+ projects,
24869
+ () => projects.length === 0 ? 'No projects yet. Create one: slop project create "My project"' : formatTable([["KEY", "NAME", "OPEN"], ...projects.map((project2) => [project2.key, project2.name, String(project2.openTaskCount)])])
24870
+ );
24871
+ });
24872
+ program3.command("members").description("List the people tasks in this project can be assigned to.").option("-p, --project <key>", "project key (default: the linked project)").action(async (options) => {
24873
+ const api = buildAuthedApiClient();
24874
+ const members2 = await listMembers(api, (await resolveProject(api, options.project)).id);
24875
+ emitOutput(members2, () => formatTable([["NAME", "EMAIL", "ROLE"], ...members2.map((member) => [member.name, member.email, member.role])]));
24876
+ });
24877
+ program3.command("statuses").alias("columns").description("The linked project's columns, in board order.").option("-p, --project <key>", "project key (default: the linked project)").action(async (options) => {
24878
+ const api = buildAuthedApiClient();
24879
+ const target = await resolveProject(api, options.project);
24880
+ const statuses = (await api.get(`/projects/${target.id}`, ApiResponses.Project)).statuses;
24881
+ emitOutput(statuses, () => formatTable([["KEY", "LABEL", "CATEGORY"], ...statuses.map((status2) => [status2.key, status2.label, status2.category])]));
24882
+ });
24883
+ const project = program3.command("project").description("Manage projects.");
24884
+ const status = project.command("status").alias("column").description("Add, rename, reorder or remove the project's columns.");
24885
+ const fetchResolvedProject = async (api, key) => api.get(`/projects/${(await resolveProject(api, key)).id}`, ApiResponses.Project);
24886
+ const requireStatusByRef = (target, ref) => {
24887
+ const found = findStatusByKeyOrLabel(target.statuses, ref);
24888
+ if (!found) throw new CliError(`"${ref}" is not a column of ${target.key}`, "slop statuses");
24889
+ return found;
24890
+ };
24891
+ const formatStatusLines = (statuses) => statuses.map((candidate) => `${candidate.key} (${candidate.label}, ${candidate.category})`).join("\n");
24892
+ status.command("add <label>").description("Add a column at the end.").option("-p, --project <key>").option("-c, --category <category>", STATUS_CATEGORIES.join(" | "), "todo").action(async (label, options) => {
24893
+ const api = buildAuthedApiClient();
24894
+ if (!STATUS_CATEGORIES.includes(options.category)) throw new CliError(`Unknown category "${options.category}"`, `use one of: ${STATUS_CATEGORIES.join(", ")}`);
24895
+ const target = await fetchResolvedProject(api, options.project);
24896
+ const statuses = await api.post(`/projects/${target.id}/statuses`, ApiResponses.ProjectStatuses, { label, category: options.category });
24897
+ emitOutput(statuses, () => formatStatusLines(statuses));
24898
+ });
24899
+ status.command("rename <column> <label>").description("Rename a column (its key stays).").option("-p, --project <key>").option("-c, --category <category>", STATUS_CATEGORIES.join(" | ")).action(async (ref, label, options) => {
24900
+ const api = buildAuthedApiClient();
24901
+ const target = await fetchResolvedProject(api, options.project);
24902
+ const statuses = await api.patch(`/projects/${target.id}/statuses/${requireStatusByRef(target, ref).id}`, ApiResponses.ProjectStatuses, { label, category: options.category });
24903
+ emitOutput(statuses, () => formatStatusLines(statuses));
24904
+ });
24905
+ status.command("remove <column>").description("Remove a column; its tasks move to --to.").option("-p, --project <key>").option("--to <column>", "where the column's tasks go").action(async (ref, options) => {
24906
+ const api = buildAuthedApiClient();
24907
+ const target = await fetchResolvedProject(api, options.project);
24908
+ const moveTo = options.to ? requireStatusByRef(target, options.to).key : null;
24909
+ const statuses = await api.delete(`/projects/${target.id}/statuses/${requireStatusByRef(target, ref).id}${moveTo ? `?moveTo=${encodeURIComponent(moveTo)}` : ""}`, ApiResponses.ProjectStatuses);
24910
+ emitOutput(statuses, () => formatStatusLines(statuses));
24911
+ });
24912
+ status.command("order <columns...>").description("Set the board order: every column, by key or label.").option("-p, --project <key>").action(async (refs, options) => {
24913
+ const api = buildAuthedApiClient();
24914
+ const target = await fetchResolvedProject(api, options.project);
24915
+ const statuses = await api.put(`/projects/${target.id}/statuses/order`, ApiResponses.ProjectStatuses, { ids: refs.map((ref) => requireStatusByRef(target, ref).id) });
24916
+ emitOutput(statuses, () => formatStatusLines(statuses));
24917
+ });
24918
+ project.command("update").description("Change the linked project's name or description.").option("-p, --project <key>", "project key (default: the linked project)").option("-n, --name <name>").option("-d, --description <text>").action(async (options) => {
24919
+ const api = buildAuthedApiClient();
24920
+ const target = await resolveProject(api, options.project);
24921
+ if (options.name === void 0 && options.description === void 0) {
24922
+ throw new CliError("Nothing to change", "pass --name or --description");
24923
+ }
24924
+ const updated = await api.patch(`/projects/${target.id}`, ApiResponses.Project, { name: options.name, description: options.description });
24925
+ emitOutput(updated, () => `Updated ${updated.key} ${updated.name}`);
24926
+ });
24927
+ project.command("create <name>").description("Create a project.").option("-k, --key <key>", "task key prefix, e.g. WEB (default: derived from the name)").option("-d, --description <text>", "what the project is").option("--org <slug-or-id>", "which org, if you belong to several").action(async (name, options) => {
24928
+ const api = buildAuthedApiClient();
24929
+ const orgs = await api.get("/orgs", ApiResponses.Orgs);
24930
+ const org = options.org ? orgs.find((candidate) => candidate.id === options.org || candidate.slug === options.org) : orgs.length === 1 ? orgs[0] : void 0;
24931
+ if (!org) throw new CliError(options.org ? `No org "${options.org}"` : "You belong to several orgs", `pass --org: ${orgs.map((candidate) => candidate.slug).join(", ")}`);
24932
+ const key = options.key ?? generateProjectKeyFromName(name);
24933
+ if (!key) throw new CliError("Could not derive a key from that name", "pass --key, e.g. --key WEB");
24934
+ const project2 = await api.post(`/orgs/${org.id}/projects`, ApiResponses.Project, { name, key, description: options.description ?? "" });
24935
+ emitOutput(project2, () => `Created ${project2.key} ${project2.name}
24936
+ Link this repository to it: slop link ${project2.key}`);
24937
+ });
24938
+ program3.command("link <project>").description("Bind this git repository (on this machine) to a project, by key.").action(async (projectKey) => {
24939
+ const api = buildAuthedApiClient();
24940
+ const repo = requireRepo();
24941
+ const project2 = await fetchProjectByKey(api, projectKey);
24942
+ const { info, localRepoId } = buildRepoDescription(repo, fetchRepoLink(repo));
24943
+ const binding = await api.post(`/projects/${project2.id}/bindings`, ApiResponses.Binding, { repo: info });
24944
+ persistRepoLink(repo, { baseUrl: api.baseUrl, projectId: project2.id, projectKey: project2.key, projectName: project2.name, bindingId: binding.id, localRepoId });
24945
+ emitOutput({ project: project2, binding }, () => `Linked ${info.name} on ${binding.machine.name} to ${project2.key} ${project2.name}
24946
+ Try: slop task create "My first task"`);
24947
+ });
24948
+ program3.command("unlink").description("Remove this repository's binding.").action(async () => {
24949
+ const repo = requireRepo();
24950
+ const link = fetchRepoLink(repo);
24951
+ if (!link) throw new CliError("This repository is not linked", void 0, "not_linked");
24952
+ await buildAuthedApiClient().delete(`/projects/${link.projectId}/bindings/${link.bindingId}`, ApiResponses.Ok).catch(() => {
24890
24953
  });
24954
+ deleteTaskLink(repo);
24955
+ emitOutput({ ok: true }, () => `Unlinked from ${link.projectKey}.`);
24956
+ });
24957
+ program3.command("status").description("Show what this repository is linked to.").action(async () => {
24958
+ const repo = fetchRepo();
24959
+ const link = repo ? fetchRepoLink(repo) : null;
24960
+ const where = !repo ? "" : repo.worktree === repo.root ? repo.root : `${repo.worktree} (worktree of ${repo.root})`;
24961
+ emitOutput(
24962
+ { repo: repo?.root ?? null, worktree: repo?.worktree ?? null, link },
24963
+ () => !repo ? "Not inside a git repository." : !link ? `${where}
24964
+ not linked. Run: slop link <PROJECT_KEY>` : `${where}
24965
+ linked to ${link.projectKey} ${link.projectName} (${link.baseUrl})`
24966
+ );
24891
24967
  });
24892
24968
  }
24893
24969
 
24894
- // src/platform/harness.ts
24895
- var parent = null;
24896
- function fetchParentCommand() {
24897
- if (parent !== null) return parent;
24970
+ // src/commands/mcp.ts
24971
+ import { execFileSync as execFileSync5, spawn as spawn3 } from "child_process";
24972
+ import { existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync14, writeFileSync as writeFileSync8 } from "fs";
24973
+ import { homedir as homedir4 } from "os";
24974
+ import { join as join13 } from "path";
24975
+
24976
+ // src/platform/session-recovery.ts
24977
+ import { readFileSync as readFileSync9 } from "fs";
24978
+ import { dirname as dirname6, join as join11 } from "path";
24979
+
24980
+ // src/platform/inbox.ts
24981
+ import { createHash as createHash5, randomUUID as randomUUID6 } from "crypto";
24982
+ import { existsSync as existsSync7, linkSync as linkSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync8, readdirSync as readdirSync4, realpathSync as realpathSync4, renameSync as renameSync2, rmSync as rmSync5, writeFileSync as writeFileSync6 } from "fs";
24983
+ import { dirname as dirname5, join as join10 } from "path";
24984
+ var buildInboxDir = () => join10(dirname5(buildConfigPath()), "inbox");
24985
+ var resolveRealCwd2 = (cwd) => {
24898
24986
  try {
24899
- parent = process.platform === "win32" ? "" : execFileSync4("ps", ["-o", "command=", "-p", String(process.ppid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
24987
+ return realpathSync4(cwd);
24900
24988
  } catch {
24901
- parent = "";
24989
+ return cwd;
24902
24990
  }
24903
- return parent;
24904
- }
24905
- function parseHarnessFromCommand(command) {
24906
- const first = basename2(command.trim().split(/\s+/)[0] ?? "");
24907
- if (/^claude(\b|$)/.test(first)) return "claude";
24908
- if (/^codex(\b|$)/.test(first)) return "codex";
24909
- if (/(^|\/)codex(\S*)?\s/.test(command)) return "codex";
24910
- return null;
24911
- }
24912
- function isNonInteractiveCommand(command) {
24913
- return /(^|[\s/])codex(\S*)?\s+exec(\s|$)/.test(command);
24914
- }
24915
- function isNonInteractiveHarness() {
24916
- return isNonInteractiveCommand(fetchParentCommand());
24917
- }
24918
- var isSamePath = (a, b) => {
24919
- const resolveRealPath = (path2) => {
24920
- try {
24921
- return realpathSync2(path2);
24922
- } catch {
24923
- return path2;
24924
- }
24925
- };
24926
- return resolveRealPath(a) === resolveRealPath(b);
24927
24991
  };
24928
- function isMainChatRollout(sessionMetaLine) {
24929
- if (!sessionMetaLine.includes('"session_meta"')) return false;
24930
- if (/"parent_thread_id"\s*:\s*"/.test(sessionMetaLine)) return false;
24931
- const source = /"source"\s*:\s*("([^"]*)"|\{)/.exec(sessionMetaLine);
24932
- if (!source) return true;
24933
- if (source[1] === "{") return false;
24934
- return source[2] !== "exec";
24935
- }
24936
- function fetchCodexConversationByCwd(roots, cwd) {
24937
- const rollouts = [];
24938
- const findRolloutsInDirectory = (dir, depth) => {
24939
- let entries;
24992
+ var generateInboxKey = (cwd, owner) => owner ? generateLocalStateKey(cwd, JSON.stringify([owner.baseUrl.replace(/\/$/, ""), owner.projectId, owner.conversation])) : createHash5("sha256").update(resolveRealCwd2(cwd)).digest("hex").slice(0, 16);
24993
+ var buildInboxPath = (cwd) => join10(buildInboxDir(), `${generateInboxKey(cwd)}.jsonl`);
24994
+ var buildInboxQueuePath = (cwd, owner) => join10(buildInboxDir(), generateInboxKey(cwd, owner));
24995
+ var sequence = 0;
24996
+ function persistInboxPush(cwd, push, owner) {
24997
+ const directory = buildInboxQueuePath(cwd, owner);
24998
+ mkdirSync5(directory, { recursive: true });
24999
+ const name = `${Date.now()}.${process.pid}.${String(sequence++).padStart(12, "0")}.${randomUUID6()}`;
25000
+ const temporary = join10(directory, `${name}.writing`);
25001
+ writeFileSync6(temporary, JSON.stringify(push), { flag: "wx" });
25002
+ if (owner) {
25003
+ const key = createHash5("sha256").update(push.message.id).digest("hex");
25004
+ const published = join10(directory, `${key}.json`);
24940
25005
  try {
24941
- entries = readdirSync2(dir, { withFileTypes: true });
24942
- } catch {
24943
- return;
25006
+ if (existsSync7(join10(directory, "receipts", key))) return false;
25007
+ linkSync2(temporary, published);
25008
+ return true;
25009
+ } catch (error62) {
25010
+ if (error62.code === "EEXIST") return !existsSync7(join10(directory, "receipts", key));
25011
+ throw error62;
25012
+ } finally {
25013
+ rmSync5(temporary, { force: true });
24944
25014
  }
24945
- for (const entry of entries) {
24946
- const path2 = join8(dir, entry.name);
24947
- if (entry.isDirectory()) {
24948
- if (depth < 3) findRolloutsInDirectory(path2, depth + 1);
24949
- continue;
24950
- }
24951
- const match = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(entry.name);
24952
- if (!match) continue;
25015
+ }
25016
+ renameSync2(temporary, join10(directory, `${name}.json`));
25017
+ return true;
25018
+ }
25019
+ function drainInbox(cwd, owner, accept = () => true) {
25020
+ const pushes = owner ? [] : drainLegacyInbox(cwd);
25021
+ const directory = buildInboxQueuePath(cwd, owner);
25022
+ let names;
25023
+ try {
25024
+ names = readdirSync4(directory).filter((file2) => file2.endsWith(".json")).sort();
25025
+ } catch (error62) {
25026
+ if (error62.code === "ENOENT") return pushes;
25027
+ throw error62;
25028
+ }
25029
+ if (owner) {
25030
+ const receipts = join10(directory, "receipts");
25031
+ mkdirSync5(receipts, { recursive: true });
25032
+ const available = [];
25033
+ for (const name of names) {
24953
25034
  try {
24954
- rollouts.push({ path: path2, id: match[1] ?? "", mtime: statSync3(path2).mtimeMs });
24955
- } catch {
25035
+ available.push({ name, push: JSON.parse(readFileSync8(join10(directory, name), "utf8")) });
25036
+ } catch (error62) {
25037
+ if (error62.code !== "ENOENT") throw error62;
24956
25038
  }
24957
25039
  }
24958
- };
24959
- for (const root of roots) findRolloutsInDirectory(root, 0);
24960
- rollouts.sort((a, b) => b.mtime - a.mtime);
24961
- for (const rollout of rollouts.slice(0, 300)) {
24962
- let head = "";
24963
- try {
24964
- const fd = openSync(rollout.path, "r");
25040
+ available.sort((a, b) => a.push.message.createdAt.localeCompare(b.push.message.createdAt) || a.push.message.id.localeCompare(b.push.message.id));
25041
+ for (const { name, push } of available) {
25042
+ if (!accept(push)) continue;
25043
+ const path2 = join10(directory, name);
25044
+ const receipt = join10(receipts, name.slice(0, -5));
24965
25045
  try {
24966
- const buffer = Buffer.alloc(64 * 1024);
24967
- head = buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8");
24968
- } finally {
24969
- closeSync(fd);
25046
+ writeFileSync6(receipt, "", { flag: "wx", mode: 384 });
25047
+ } catch (error62) {
25048
+ if (error62.code === "EEXIST") {
25049
+ rmSync5(path2, { force: true });
25050
+ continue;
25051
+ }
25052
+ throw error62;
24970
25053
  }
24971
- } catch {
24972
- continue;
25054
+ pushes.push(push);
25055
+ rmSync5(path2, { force: true });
24973
25056
  }
24974
- const line = head.split("\n")[0] ?? "";
24975
- if (!line.includes('"session_meta"')) continue;
24976
- if (!isMainChatRollout(line)) continue;
24977
- const found = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(line);
24978
- if (!found) continue;
24979
- let dir;
25057
+ return pushes;
25058
+ }
25059
+ for (const name of names) {
25060
+ const taken = join10(directory, `${name}.${randomUUID6()}.draining`);
24980
25061
  try {
24981
- dir = JSON.parse(`"${found[1]}"`);
24982
- } catch {
24983
- continue;
25062
+ renameSync2(join10(directory, name), taken);
25063
+ } catch (error62) {
25064
+ if (error62.code === "ENOENT") continue;
25065
+ throw error62;
25066
+ }
25067
+ try {
25068
+ pushes.push(JSON.parse(readFileSync8(taken, "utf8")));
25069
+ } finally {
25070
+ rmSync5(taken, { force: true });
24984
25071
  }
24985
- if (isSamePath(dir, cwd)) return rollout.id;
24986
25072
  }
24987
- return null;
25073
+ return pushes;
24988
25074
  }
24989
- function isMainChatThread(roots, threadId) {
24990
- const path2 = fetchCodexRolloutPath(roots, threadId);
24991
- if (!path2) return true;
25075
+ function drainLegacyInbox(cwd) {
25076
+ const path2 = buildInboxPath(cwd);
25077
+ if (!existsSync7(path2)) return [];
25078
+ const taken = `${path2}.${process.pid}.${Date.now()}.draining`;
24992
25079
  try {
24993
- const fd = openSync(path2, "r");
25080
+ renameSync2(path2, taken);
25081
+ } catch {
25082
+ return [];
25083
+ }
25084
+ const lines = readFileSync8(taken, "utf8").split("\n").filter(Boolean);
25085
+ rmSync5(taken, { force: true });
25086
+ return lines.flatMap((line) => {
24994
25087
  try {
24995
- const buffer = Buffer.alloc(64 * 1024);
24996
- const head = buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8");
24997
- return isMainChatRollout(head.split("\n")[0] ?? "");
25088
+ return [JSON.parse(line)];
25089
+ } catch {
25090
+ return [];
25091
+ }
25092
+ });
25093
+ }
25094
+
25095
+ // src/platform/session-recovery.ts
25096
+ var SessionRecovery = class {
25097
+ constructor(api, owner, cwd, deliverPush) {
25098
+ this.api = api;
25099
+ this.owner = owner;
25100
+ this.deliverPush = deliverPush;
25101
+ this.path = join11(dirname6(buildConfigPath()), "cursors", `${generateInboxKey(cwd, owner)}.json`);
25102
+ }
25103
+ api;
25104
+ owner;
25105
+ deliverPush;
25106
+ cursor = null;
25107
+ preparing = null;
25108
+ syncing = null;
25109
+ resync = false;
25110
+ retry = null;
25111
+ stopped = false;
25112
+ path;
25113
+ syncCursor() {
25114
+ return this.preparing ??= this.fetchCursor().catch((error62) => {
25115
+ this.preparing = null;
25116
+ throw error62;
25117
+ });
25118
+ }
25119
+ async fetchCursor() {
25120
+ try {
25121
+ const value = JSON.parse(readFileSync9(this.path, "utf8"));
25122
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("Invalid saved message cursor");
25123
+ this.cursor = value;
25124
+ } catch (error62) {
25125
+ if (error62.code !== "ENOENT") throw error62;
25126
+ const page = await this.api.get(`/projects/${this.owner.projectId}/session/messages`, ApiResponses.SessionMessages);
25127
+ persistLocalState(this.path, JSON.stringify(page.cursor), false);
25128
+ await this.fetchCursor();
25129
+ }
25130
+ }
25131
+ sync() {
25132
+ if (this.stopped) return Promise.resolve();
25133
+ if (this.syncing) {
25134
+ this.resync = true;
25135
+ return this.syncing;
25136
+ }
25137
+ return this.syncing ??= this.syncMessages().catch(() => {
25138
+ if (!this.stopped && !this.retry) this.retry = setTimeout(() => {
25139
+ this.retry = null;
25140
+ void this.sync();
25141
+ }, 2e4);
25142
+ }).finally(() => {
25143
+ this.syncing = null;
25144
+ });
25145
+ }
25146
+ async syncMessages() {
25147
+ await this.syncCursor();
25148
+ if (this.retry) clearTimeout(this.retry);
25149
+ this.retry = null;
25150
+ while (!this.stopped) {
25151
+ this.resync = false;
25152
+ const page = await this.api.get(`/projects/${this.owner.projectId}/session/messages?cursor=${this.cursor}`, ApiResponses.SessionMessages);
25153
+ if (this.stopped) return;
25154
+ if (page.cursor < this.cursor || page.hasMore && page.cursor === this.cursor) throw new Error("Message recovery did not advance");
25155
+ for (const push of page.pushes) this.deliverPush(push);
25156
+ persistLocalState(this.path, JSON.stringify(page.cursor));
25157
+ this.cursor = page.cursor;
25158
+ if (!page.hasMore && !this.resync) return;
25159
+ }
25160
+ }
25161
+ stop() {
25162
+ this.stopped = true;
25163
+ if (this.retry) clearTimeout(this.retry);
25164
+ this.retry = null;
25165
+ }
25166
+ };
25167
+
25168
+ // src/platform/live.ts
25169
+ var PING_EVERY_MS = 25e3;
25170
+ var PONG_WITHIN_MS = 1e4;
25171
+ var STANDBY_POLL_MS = 2e4;
25172
+ var HELLO_WITHIN_MS = 1e4;
25173
+ function parseSessionPush(data) {
25174
+ try {
25175
+ const parsed = SessionPush.safeParse(JSON.parse(data));
25176
+ return parsed.success ? parsed.data : null;
25177
+ } catch {
25178
+ return null;
25179
+ }
25180
+ }
25181
+ async function waitForMessage(api, projectId, taskKey, options = {}) {
25182
+ return new Promise((resolve6, reject) => {
25183
+ let finished = false;
25184
+ const finishWait = (outcome) => {
25185
+ if (finished) return;
25186
+ finished = true;
25187
+ if (timer) clearTimeout(timer);
25188
+ held.stop(outcome.kind === "message" ? "handled" : outcome.kind === "timeout" ? "timeout" : "released");
25189
+ setTimeout(() => resolve6(outcome), 50);
25190
+ };
25191
+ const held = new HeldSession(api, projectId, taskKey, {
25192
+ claim: () => options.claim ?? { harness: null, directory: null, branch: null },
25193
+ reconnect: false,
25194
+ onHello: options.onHello,
25195
+ onEnd: () => finishWait({ kind: "ended" }),
25196
+ onDrop: (reason) => finishWait({ kind: "dropped", reason }),
25197
+ onMessage: (push) => {
25198
+ if (!options.onMessage?.(push)) finishWait({ kind: "message", push });
25199
+ }
25200
+ });
25201
+ const timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => finishWait({ kind: "timeout" }), options.timeoutMs) : null;
25202
+ void held.start().catch((error62) => {
25203
+ if (finished) return;
25204
+ finished = true;
25205
+ if (timer) clearTimeout(timer);
25206
+ held.stop();
25207
+ reject(error62);
25208
+ });
25209
+ });
25210
+ }
25211
+ var HeldSession = class {
25212
+ constructor(api, projectId, taskKey, hooks) {
25213
+ this.api = api;
25214
+ this.projectId = projectId;
25215
+ this.taskKey = taskKey;
25216
+ this.hooks = hooks;
25217
+ this.identity = { ...hooks.claim() };
25218
+ }
25219
+ api;
25220
+ projectId;
25221
+ taskKey;
25222
+ hooks;
25223
+ socket = null;
25224
+ stopped = false;
25225
+ connecting = false;
25226
+ timer = null;
25227
+ heartbeat = null;
25228
+ pongDue = null;
25229
+ helloDue = null;
25230
+ greetings = /* @__PURE__ */ new Set();
25231
+ attempt = 0;
25232
+ connected = 0;
25233
+ delivered = /* @__PURE__ */ new Set();
25234
+ identity;
25235
+ session = null;
25236
+ /**
25237
+ * Whoever is waiting for a message. Handed each one, and answers whether it took it (a waiter for one thread lets
25238
+ * the rest pass to the inbox); handed null when the agent is made unreachable under them.
25239
+ */
25240
+ waiters = /* @__PURE__ */ new Set();
25241
+ /** A write needs an accepted identity. A timeout must fail rather than silently send as another agent. */
25242
+ requireSession() {
25243
+ if (this.stopped) return Promise.reject(new Error("Session ended; start a new session before writing"));
25244
+ if (this.session) return Promise.resolve(this.session);
25245
+ return new Promise((resolve6, reject) => {
25246
+ const finishGreeting = (session) => {
25247
+ clearTimeout(timer);
25248
+ this.greetings.delete(finishGreeting);
25249
+ if (session) resolve6(session);
25250
+ else reject(new Error("Session is not connected; retry when the connection recovers"));
25251
+ };
25252
+ const timer = setTimeout(() => finishGreeting(null), HELLO_WITHIN_MS);
25253
+ this.greetings.add(finishGreeting);
25254
+ });
25255
+ }
25256
+ get isStopped() {
25257
+ return this.stopped;
25258
+ }
25259
+ async start() {
25260
+ await this.connect();
25261
+ }
25262
+ async connect() {
25263
+ if (this.stopped || this.connecting || this.socket) return;
25264
+ this.connecting = true;
25265
+ if (this.timer) clearTimeout(this.timer);
25266
+ try {
25267
+ const ticket = await this.api.post(this.taskKey ? `/projects/${this.projectId}/tasks/${encodeURIComponent(this.taskKey)}/session/ticket` : `/projects/${this.projectId}/session/ticket`, ApiResponses.ConnectTicket, this.identity);
25268
+ if (this.stopped) return;
25269
+ const socket = new WebSocket(`${this.api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
25270
+ this.socket = socket;
25271
+ let greeted = false;
25272
+ this.helloDue = setTimeout(() => this.retryConnection(socket, "no hello"), HELLO_WITHIN_MS);
25273
+ socket.addEventListener("open", () => {
25274
+ if (this.stopped || this.socket !== socket) return;
25275
+ this.startHeartbeat(socket);
25276
+ });
25277
+ socket.addEventListener("message", (event) => {
25278
+ if (this.stopped || this.socket !== socket) return;
25279
+ const data = String(event.data);
25280
+ if (data === "pong") {
25281
+ if (this.pongDue) clearTimeout(this.pongDue);
25282
+ this.pongDue = null;
25283
+ return;
25284
+ }
25285
+ const push = parseSessionPush(data);
25286
+ if (!push) return;
25287
+ if (push.type === "hello") {
25288
+ if (greeted) return;
25289
+ try {
25290
+ persistRememberedSession(process.cwd(), push.session.id, this.identity.conversation);
25291
+ } catch {
25292
+ this.retryConnection(socket, "cannot save session identity");
25293
+ return;
25294
+ }
25295
+ greeted = true;
25296
+ this.api.sessionId = push.session.id;
25297
+ this.session = push.session;
25298
+ this.taskKey = push.session.taskKey;
25299
+ if (this.helloDue) clearTimeout(this.helloDue);
25300
+ this.helloDue = null;
25301
+ this.attempt = 0;
25302
+ for (const greeting of [...this.greetings]) greeting(push.session);
25303
+ this.hooks.onHello?.(push.session);
25304
+ this.connected += 1;
25305
+ if (this.connected > 1) void Promise.resolve().then(() => !this.stopped && this.socket === socket ? this.hooks.onReconnect?.(push.session) : void 0).catch(() => void 0);
25306
+ return;
25307
+ }
25308
+ if (push.type === "end") {
25309
+ this.hooks.onEnd?.();
25310
+ this.stop();
25311
+ return;
25312
+ }
25313
+ if (push.type === "reachable") {
25314
+ if (this.session) this.session = { ...this.session, reachable: push.on };
25315
+ if (!push.on) this.releaseWaiters();
25316
+ this.hooks.onReachable?.(push.on);
25317
+ return;
25318
+ }
25319
+ if (push.type !== "thread") return;
25320
+ try {
25321
+ this.deliverPush(push);
25322
+ } catch {
25323
+ this.retryConnection(socket, "delivery failed");
25324
+ }
25325
+ });
25326
+ socket.addEventListener("close", (event) => {
25327
+ if (this.socket !== socket) return;
25328
+ this.stopHeartbeat();
25329
+ if (this.helloDue) clearTimeout(this.helloDue);
25330
+ this.helloDue = null;
25331
+ this.socket = null;
25332
+ if (this.stopped) return;
25333
+ if (event.reason === "ended") {
25334
+ this.hooks.onEnd?.();
25335
+ this.stop();
25336
+ return;
25337
+ }
25338
+ this.hooks.onDrop?.(event.reason || `code ${event.code}`);
25339
+ if (this.hooks.reconnect === false) {
25340
+ this.stop();
25341
+ return;
25342
+ }
25343
+ if (event.reason === "replaced") this.standBy();
25344
+ else this.schedule();
25345
+ });
25346
+ socket.addEventListener("error", () => {
25347
+ this.retryConnection(socket, "socket error");
25348
+ });
25349
+ } catch (error62) {
25350
+ if (this.hooks.reconnect === false) {
25351
+ this.stop();
25352
+ throw error62;
25353
+ }
25354
+ this.hooks.onDrop?.(error62 instanceof Error ? error62.message : String(error62));
25355
+ this.schedule();
24998
25356
  } finally {
24999
- closeSync(fd);
25357
+ this.connecting = false;
25000
25358
  }
25001
- } catch {
25002
- return true;
25003
25359
  }
25004
- }
25005
- function fetchCodexRolloutPath(roots, threadId) {
25006
- const suffix = `-${threadId}.jsonl`;
25007
- let best = null;
25008
- const findRolloutInDirectory = (dir, depth) => {
25009
- let entries;
25010
- try {
25011
- entries = readdirSync2(dir, { withFileTypes: true });
25012
- } catch {
25013
- return;
25014
- }
25015
- for (const entry of entries) {
25016
- const path2 = join8(dir, entry.name);
25017
- if (entry.isDirectory()) {
25018
- if (depth < 3) findRolloutInDirectory(path2, depth + 1);
25019
- continue;
25360
+ /** Live and recovered messages follow exactly the same local delivery path. */
25361
+ deliverPush(push) {
25362
+ if (this.stopped || this.session?.reachable === false || this.delivered.has(push.message.id)) return;
25363
+ if (this.hooks.onPush?.(push) === false) return;
25364
+ for (const waiter of [...this.waiters]) {
25365
+ if (waiter(push)) {
25366
+ this.markPushDelivered(push.message.id);
25367
+ return;
25020
25368
  }
25021
- if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(suffix)) continue;
25369
+ }
25370
+ this.hooks.onMessage(push);
25371
+ this.markPushDelivered(push.message.id);
25372
+ }
25373
+ markPushDelivered(id) {
25374
+ this.delivered.add(id);
25375
+ if (this.delivered.size > 1e4) this.delivered.delete(this.delivered.values().next().value);
25376
+ }
25377
+ /** A ping every 25s; no pong within 10s means the socket is dead however open it looks. Close it and come back. */
25378
+ startHeartbeat(socket) {
25379
+ this.stopHeartbeat();
25380
+ this.heartbeat = setInterval(() => {
25381
+ if (socket.readyState !== WebSocket.OPEN) return;
25022
25382
  try {
25023
- const mtime = statSync3(path2).mtimeMs;
25024
- if (!best || mtime > best.mtime) best = { path: path2, mtime };
25383
+ socket.send("ping");
25025
25384
  } catch {
25385
+ this.retryConnection(socket, "ping failed");
25386
+ return;
25026
25387
  }
25027
- }
25028
- };
25029
- for (const root of roots) findRolloutInDirectory(root, 0);
25030
- return best ? best.path : null;
25031
- }
25032
- function fetchCodexTurnState(roots, threadId) {
25033
- const path2 = fetchCodexRolloutPath(roots, threadId);
25034
- if (!path2) return "unknown";
25035
- let tail = "";
25036
- let mtime = 0;
25037
- try {
25038
- const stats = statSync3(path2);
25039
- mtime = stats.mtimeMs;
25040
- const size = stats.size;
25041
- const length = Math.min(size, 256 * 1024);
25042
- const fd = openSync(path2, "r");
25388
+ if (!this.pongDue) {
25389
+ this.pongDue = setTimeout(() => {
25390
+ this.pongDue = null;
25391
+ this.retryConnection(socket, "no pong");
25392
+ }, PONG_WITHIN_MS);
25393
+ }
25394
+ }, PING_EVERY_MS);
25395
+ }
25396
+ /** Detach first: close may arrive late or never, and an old callback must not change its replacement. */
25397
+ retryConnection(socket, reason) {
25398
+ if (this.stopped || this.socket !== socket) return;
25399
+ this.socket = null;
25400
+ this.stopHeartbeat();
25401
+ if (this.helloDue) clearTimeout(this.helloDue);
25402
+ this.helloDue = null;
25043
25403
  try {
25044
- const buffer = Buffer.alloc(length);
25045
- tail = buffer.subarray(0, readSync(fd, buffer, 0, length, size - length)).toString("utf8");
25046
- } finally {
25047
- closeSync(fd);
25404
+ socket.close(4e3, reason);
25405
+ } catch {
25048
25406
  }
25049
- } catch {
25050
- return "unknown";
25407
+ this.hooks.onDrop?.(reason);
25408
+ if (this.hooks.reconnect !== false) this.schedule();
25409
+ else this.stop();
25051
25410
  }
25052
- let state = "unknown";
25053
- for (const line of tail.split("\n")) {
25054
- if (!line.includes('"event_msg"')) continue;
25055
- if (line.includes('"task_started"')) state = "busy";
25056
- else if (line.includes('"task_complete"') || line.includes('"turn_aborted"')) state = "idle";
25411
+ releaseWaiters() {
25412
+ const waiters = [...this.waiters];
25413
+ this.waiters.clear();
25414
+ for (const waiter of waiters) waiter(null);
25057
25415
  }
25058
- if (state === "busy" && Date.now() - mtime > STALE_TURN_MS) return "idle";
25059
- return state;
25060
- }
25061
- var STALE_TURN_MS = 15 * 60 * 1e3;
25062
- function resolveCodexBinary() {
25063
- try {
25064
- execFileSync4(process.platform === "win32" ? "where" : "which", ["codex"], { stdio: "ignore" });
25065
- return "codex";
25066
- } catch {
25416
+ stopHeartbeat() {
25417
+ if (this.heartbeat) clearInterval(this.heartbeat);
25418
+ if (this.pongDue) clearTimeout(this.pongDue);
25419
+ this.heartbeat = null;
25420
+ this.pongDue = null;
25067
25421
  }
25068
- if (parseHarnessFromCommand(fetchParentCommand()) === "codex" && process.platform !== "win32") {
25422
+ schedule() {
25423
+ if (this.stopped) return;
25424
+ if (this.timer) clearTimeout(this.timer);
25425
+ this.attempt += 1;
25426
+ this.timer = setTimeout(() => void this.connect(), Math.min(3e4, 1e3 * 2 ** Math.min(this.attempt, 5)));
25427
+ }
25428
+ /** Another process holds this session now: look now and then, and take it back once it is no longer live. */
25429
+ standBy() {
25430
+ if (this.stopped) return;
25431
+ if (this.timer) clearTimeout(this.timer);
25432
+ this.timer = setTimeout(async () => {
25433
+ if (this.stopped) return;
25434
+ let holder = true;
25435
+ try {
25436
+ const sessions = await this.api.get(`/projects/${this.projectId}/sessions`, ApiResponses.Sessions);
25437
+ holder = sessions.some((session) => session.id === this.session?.id && session.state === "live");
25438
+ } catch {
25439
+ }
25440
+ if (holder) this.standBy();
25441
+ else void this.connect();
25442
+ }, STANDBY_POLL_MS);
25443
+ }
25444
+ stop(reason = "released") {
25445
+ if (this.stopped) return;
25446
+ this.stopped = true;
25447
+ this.releaseWaiters();
25448
+ for (const greeting of [...this.greetings]) greeting(null);
25449
+ if (this.helloDue) clearTimeout(this.helloDue);
25450
+ this.helloDue = null;
25451
+ if (reason !== "handled" && this.api.sessionId === this.session?.id) this.api.sessionId = null;
25452
+ if (this.timer) clearTimeout(this.timer);
25453
+ this.stopHeartbeat();
25454
+ const socket = this.socket;
25455
+ this.socket = null;
25069
25456
  try {
25070
- const exe = process.platform === "linux" ? readlinkSync(`/proc/${process.ppid}/exe`) : execFileSync4("ps", ["-o", "comm=", "-p", String(process.ppid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
25071
- if (exe && basename2(exe).startsWith("codex")) return exe;
25457
+ socket?.close(1e3, reason);
25072
25458
  } catch {
25073
25459
  }
25074
25460
  }
25075
- return "codex";
25076
- }
25077
- function fetchHarnessConversation(cwd) {
25078
- const harness = parseHarnessFromCommand(fetchParentCommand());
25079
- if (harness === "claude") {
25080
- const id = fetchHarnessRegistration()?.sessionId;
25081
- return id ? { id, harness } : null;
25082
- }
25083
- if (harness === "codex" && !isNonInteractiveHarness()) {
25084
- const id = fetchCodexConversationByCwd(fetchCodexHomes().map((home) => join8(home, "sessions")), cwd);
25085
- return id ? { id, harness } : null;
25086
- }
25087
- return null;
25088
- }
25089
-
25090
- // src/platform/agent-identity.ts
25091
- var processConversation = randomUUID4();
25092
- var startedAt = Date.now();
25093
- function fetchKnownConversation(cwd) {
25094
- const remembered = fetchRememberedConversation(cwd, startedAt - 12e4);
25095
- if (remembered?.harness === "codex" && isMainChatThread(fetchCodexHomes().map((home) => join9(home, "sessions")), remembered.id)) return remembered;
25096
- return fetchHarnessConversation(cwd) ?? remembered;
25097
- }
25098
- function resolveAgentIdentity(cwd, fallback = "process") {
25099
- const conversation = fetchKnownConversation(cwd);
25100
- const repo = fetchRepo();
25101
- return {
25102
- conversation: conversation?.id ?? (fallback === "process" ? processConversation : `dir:${resolve3(cwd)}`),
25103
- harness: conversation?.harness ?? null,
25104
- directory: repo ? basename3(repo.worktree) : basename3(cwd),
25105
- branch: fetchCurrentBranch(cwd)
25106
- };
25107
- }
25461
+ };
25108
25462
 
25109
25463
  // src/platform/bounded.ts
25110
25464
  var RESULT_CAP = 16e3;
@@ -25117,13 +25471,23 @@ function truncateToolResult(result) {
25117
25471
 
25118
25472
  // src/platform/mcp.ts
25119
25473
  import { createInterface } from "readline";
25120
- function serveMcp(tools, info) {
25121
- const sendJsonRpcMessage = (message) => process.stdout.write(`${JSON.stringify(message)}
25122
- `);
25474
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25475
+ function buildMcpRequestHandler(tools, info, sendJsonRpcMessage) {
25123
25476
  const sendJsonRpcResult = (id, result) => sendJsonRpcMessage({ jsonrpc: "2.0", id, result });
25124
25477
  const sendJsonRpcError = (id, code, message) => sendJsonRpcMessage({ jsonrpc: "2.0", id, error: { code, message } });
25125
- const handleJsonRpcRequest = async (request) => {
25126
- const { id, method, params = {} } = request;
25478
+ return async (value) => {
25479
+ if (!isRecord(value) || value.jsonrpc !== "2.0" || typeof value.method !== "string" || value.id !== void 0 && value.id !== null && typeof value.id !== "string" && typeof value.id !== "number") {
25480
+ sendJsonRpcError(null, -32600, "Invalid request");
25481
+ return;
25482
+ }
25483
+ const request = value;
25484
+ const { id, method } = request;
25485
+ if (id === void 0) return;
25486
+ if (request.params !== void 0 && !isRecord(request.params)) {
25487
+ sendJsonRpcError(id, -32602, "Params must be an object");
25488
+ return;
25489
+ }
25490
+ const params = request.params ?? {};
25127
25491
  switch (method) {
25128
25492
  case "initialize":
25129
25493
  sendJsonRpcResult(id, { protocolVersion: params.protocolVersion ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: info });
@@ -25139,6 +25503,10 @@ function serveMcp(tools, info) {
25139
25503
  return;
25140
25504
  case "tools/call": {
25141
25505
  const name = params.name;
25506
+ if (typeof name !== "string" || params.arguments !== void 0 && !isRecord(params.arguments)) {
25507
+ sendJsonRpcError(id, -32602, "Tool name and arguments are invalid");
25508
+ return;
25509
+ }
25142
25510
  const tool = tools.find((candidate) => candidate.name === name);
25143
25511
  if (!tool) {
25144
25512
  sendJsonRpcError(id, -32602, `No tool named ${name}`);
@@ -25156,6 +25524,11 @@ function serveMcp(tools, info) {
25156
25524
  if (id !== void 0) sendJsonRpcError(id, -32601, `Unknown method ${method}`);
25157
25525
  }
25158
25526
  };
25527
+ }
25528
+ function serveMcp(tools, info) {
25529
+ const sendJsonRpcMessage = (message) => process.stdout.write(`${JSON.stringify(message)}
25530
+ `);
25531
+ const handleJsonRpcRequest = buildMcpRequestHandler(tools, info, sendJsonRpcMessage);
25159
25532
  const lines = createInterface({ input: process.stdin });
25160
25533
  lines.on("line", (line) => {
25161
25534
  if (!line.trim()) return;
@@ -25163,20 +25536,19 @@ function serveMcp(tools, info) {
25163
25536
  try {
25164
25537
  request = JSON.parse(line);
25165
25538
  } catch {
25166
- sendJsonRpcError(null, -32700, "Parse error");
25539
+ sendJsonRpcMessage({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
25167
25540
  return;
25168
25541
  }
25169
25542
  void handleJsonRpcRequest(request);
25170
25543
  });
25171
- lines.on("close", () => process.exit(0));
25172
25544
  }
25173
25545
 
25174
25546
  // src/commands/tasks.ts
25175
- import { readFileSync as readFileSync9 } from "fs";
25547
+ import { readFileSync as readFileSync11 } from "fs";
25176
25548
 
25177
25549
  // src/commands/items.ts
25178
- import { readFileSync as readFileSync8 } from "fs";
25179
- var fetchTextArgument = (value) => value === "-" ? readFileSync8(0, "utf8").trim() : value;
25550
+ import { readFileSync as readFileSync10 } from "fs";
25551
+ var fetchTextArgument = (value) => value === "-" ? readFileSync10(0, "utf8").trim() : value;
25180
25552
  var buildItemsPath = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}/items`;
25181
25553
  function formatPlanItems(items) {
25182
25554
  if (items.length === 0) return "No plan yet. Write one: slop task plan <KEY> -";
@@ -25203,7 +25575,7 @@ function registerItemCommands(task, registerProjectOption) {
25203
25575
  registerProjectOption(task.command("plan <key> [lines...]")).description('Append items to the plan, one per argument; "-" reads one per line from stdin (list markers are fine).').action(async (key, lines, options) => {
25204
25576
  const api = buildAuthedApiClient();
25205
25577
  const project = await resolveProject(api, options.project);
25206
- const source = lines.length === 1 && lines[0] === "-" ? readFileSync8(0, "utf8") : lines.join("\n");
25578
+ const source = lines.length === 1 && lines[0] === "-" ? readFileSync10(0, "utf8") : lines.join("\n");
25207
25579
  const titles = source.split("\n").map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s*(?:\[[ xX]\]\s*)?/, "").trim()).filter(Boolean);
25208
25580
  if (titles.length === 0) throw new CliError("Nothing to add", 'give items as arguments, or "-" and one per line on stdin');
25209
25581
  const items = await api.post(buildItemsPath(project.id, key), ApiResponses.TaskItems, { items: titles.map((title3) => ({ title: title3 })) });
@@ -25260,7 +25632,7 @@ function parseOneOf(value, allowed, what) {
25260
25632
  if (allowed.includes(normalized)) return normalized;
25261
25633
  throw new CliError(`Unknown ${what} "${value}"`, `use one of: ${allowed.join(", ")}`);
25262
25634
  }
25263
- var fetchTextArgument2 = (value) => value === "-" ? readFileSync9(0, "utf8").trim() : value;
25635
+ var fetchTextArgument2 = (value) => value === "-" ? readFileSync11(0, "utf8").trim() : value;
25264
25636
  var buildTaskUrl = (baseUrl, projectId, task) => `${baseUrl}/projects/${projectId}?task=${task.key}`;
25265
25637
  var fetchProject = (api, id) => api.get(`/projects/${id}`, ApiResponses.Project);
25266
25638
  function resolveStatus(project, ref) {
@@ -25411,21 +25783,21 @@ ${buildTaskUrl(api.baseUrl, project.id, created)}`);
25411
25783
  }
25412
25784
 
25413
25785
  // src/platform/update-check.ts
25414
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
25415
- import { dirname as dirname4, join as join10 } from "path";
25786
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
25787
+ import { dirname as dirname7, join as join12 } from "path";
25416
25788
  var CHECK_EVERY_MS = 60 * 60 * 1e3;
25417
25789
  var REGISTRY = "https://registry.npmjs.org/slopwaresystems/latest";
25418
- var buildUpdateCachePath = () => join10(dirname4(buildConfigPath()), "update-check.json");
25790
+ var buildUpdateCachePath = () => join12(dirname7(buildConfigPath()), "update-check.json");
25419
25791
  function fetchUpdateCache() {
25420
25792
  try {
25421
- return JSON.parse(readFileSync10(buildUpdateCachePath(), "utf8"));
25793
+ return JSON.parse(readFileSync12(buildUpdateCachePath(), "utf8"));
25422
25794
  } catch {
25423
25795
  return { checkedAt: 0, latest: null };
25424
25796
  }
25425
25797
  }
25426
25798
  function persistUpdateCache(cache) {
25427
- mkdirSync5(dirname4(buildUpdateCachePath()), { recursive: true });
25428
- writeFileSync6(buildUpdateCachePath(), JSON.stringify(cache));
25799
+ mkdirSync6(dirname7(buildUpdateCachePath()), { recursive: true });
25800
+ writeFileSync7(buildUpdateCachePath(), JSON.stringify(cache));
25429
25801
  }
25430
25802
  function isNewerVersion(a, b) {
25431
25803
  const parseVersionParts = (version2) => version2.split(".").map((part) => Number.parseInt(part, 10));
@@ -25454,11 +25826,11 @@ function buildUpdateNotice(current) {
25454
25826
  if (!latest || !isNewerVersion(latest, current)) return null;
25455
25827
  return `Update available: slopwaresystems ${latest} (this is ${current}). Ask your person to run: npm i -g slopwaresystems && slop mcp install`;
25456
25828
  }
25457
- var isUpdateCheckEnabled = () => !existsSync8(join10(dirname4(buildConfigPath()), "no-update-check")) && process.env.SLOP_NO_UPDATE_CHECK !== "1";
25829
+ var isUpdateCheckEnabled = () => !existsSync8(join12(dirname7(buildConfigPath()), "no-update-check")) && process.env.SLOP_NO_UPDATE_CHECK !== "1";
25458
25830
 
25459
25831
  // src/commands/threads.ts
25460
- import { readFileSync as readFileSync11 } from "fs";
25461
- var fetchTextArgument3 = (value) => value === "-" ? readFileSync11(0, "utf8").trim() : value;
25832
+ import { readFileSync as readFileSync13 } from "fs";
25833
+ var fetchTextArgument3 = (value) => value === "-" ? readFileSync13(0, "utf8").trim() : value;
25462
25834
  var formatMessageTime = (iso) => iso.slice(0, 16).replace("T", " ");
25463
25835
  var formatAuthor = (message) => message.via !== "agent" ? message.author.name : `${message.agent ?? message.author.name} (${message.agent ? `${message.author.name}'s agent` : "agent"}${message.machine ? ` \xB7 ${message.machine}` : ""})`;
25464
25836
  function formatThreadLine(thread) {
@@ -25529,85 +25901,6 @@ function registerThreadCommands(program3, task) {
25529
25901
  }
25530
25902
  var formatStartedLine = (result) => result.thread.to.length ? `Started ${result.thread.id} to ${result.thread.to.map((recipient) => recipient.agent).join(", ")}${result.delivered ? `; ${result.delivered} of them ha${result.delivered === 1 ? "s" : "ve"} it now.` : "; they read it when they are 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.`;
25531
25903
 
25532
- // src/platform/inbox.ts
25533
- import { createHash as createHash4, randomUUID as randomUUID5 } from "crypto";
25534
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync12, readdirSync as readdirSync3, realpathSync as realpathSync3, renameSync, rmSync as rmSync4, writeFileSync as writeFileSync7 } from "fs";
25535
- import { dirname as dirname5, join as join11 } from "path";
25536
- var buildInboxDir = () => join11(dirname5(buildConfigPath()), "inbox");
25537
- var resolveRealCwd2 = (cwd) => {
25538
- try {
25539
- return realpathSync3(cwd);
25540
- } catch {
25541
- return cwd;
25542
- }
25543
- };
25544
- var generateInboxKey = (cwd) => createHash4("sha256").update(resolveRealCwd2(cwd)).digest("hex").slice(0, 16);
25545
- var buildInboxPath = (cwd) => join11(buildInboxDir(), `${generateInboxKey(cwd)}.jsonl`);
25546
- var buildInboxQueuePath = (cwd) => join11(buildInboxDir(), generateInboxKey(cwd));
25547
- var sequence = 0;
25548
- function persistInboxPush(cwd, push) {
25549
- const directory = buildInboxQueuePath(cwd);
25550
- mkdirSync6(directory, { recursive: true });
25551
- const name = `${Date.now()}.${process.pid}.${String(sequence++).padStart(12, "0")}.${randomUUID5()}`;
25552
- const temporary = join11(directory, `${name}.writing`);
25553
- writeFileSync7(temporary, JSON.stringify(push), { flag: "wx" });
25554
- renameSync(temporary, join11(directory, `${name}.json`));
25555
- }
25556
- function hasInboxPushes(cwd) {
25557
- if (existsSync9(buildInboxPath(cwd))) return true;
25558
- try {
25559
- return readdirSync3(buildInboxQueuePath(cwd)).some((file2) => file2.endsWith(".json"));
25560
- } catch (error62) {
25561
- if (error62.code === "ENOENT") return false;
25562
- throw error62;
25563
- }
25564
- }
25565
- function drainInbox(cwd) {
25566
- const pushes = drainLegacyInbox(cwd);
25567
- const directory = buildInboxQueuePath(cwd);
25568
- let names;
25569
- try {
25570
- names = readdirSync3(directory).filter((file2) => file2.endsWith(".json")).sort();
25571
- } catch (error62) {
25572
- if (error62.code === "ENOENT") return pushes;
25573
- throw error62;
25574
- }
25575
- for (const name of names) {
25576
- const taken = join11(directory, `${name}.${randomUUID5()}.draining`);
25577
- try {
25578
- renameSync(join11(directory, name), taken);
25579
- } catch (error62) {
25580
- if (error62.code === "ENOENT") continue;
25581
- throw error62;
25582
- }
25583
- try {
25584
- pushes.push(JSON.parse(readFileSync12(taken, "utf8")));
25585
- } finally {
25586
- rmSync4(taken, { force: true });
25587
- }
25588
- }
25589
- return pushes;
25590
- }
25591
- function drainLegacyInbox(cwd) {
25592
- const path2 = buildInboxPath(cwd);
25593
- if (!existsSync9(path2)) return [];
25594
- const taken = `${path2}.${process.pid}.${Date.now()}.draining`;
25595
- try {
25596
- renameSync(path2, taken);
25597
- } catch {
25598
- return [];
25599
- }
25600
- const lines = readFileSync12(taken, "utf8").split("\n").filter(Boolean);
25601
- rmSync4(taken, { force: true });
25602
- return lines.flatMap((line) => {
25603
- try {
25604
- return [JSON.parse(line)];
25605
- } catch {
25606
- return [];
25607
- }
25608
- });
25609
- }
25610
-
25611
25904
  // src/commands/mcp.ts
25612
25905
  var buildStringSchema = (description2) => ({ type: "string", description: description2 });
25613
25906
  var buildIntegerSchema = (description2) => ({ type: "integer", description: description2 });
@@ -25626,143 +25919,104 @@ var formatSessionLine = (session, mine = null) => [
25626
25919
  session.reachable ? "" : "(not visible to teammates)"
25627
25920
  ].filter(Boolean).join(" ");
25628
25921
  async function buildUnreadDigest(api, projectId, taskKey) {
25629
- const [about, mine, general] = await Promise.all([
25922
+ const [about, general] = await Promise.all([
25630
25923
  taskKey ? api.get(`/projects/${projectId}/threads?task=${encodeURIComponent(taskKey)}&unread=1&read=1`, ApiResponses.Threads) : Promise.resolve([]),
25631
- api.get(`/projects/${projectId}/threads?participant=1&unread=1&read=1`, ApiResponses.Threads),
25632
25924
  api.get(`/projects/${projectId}/threads?scope=general&unread=1&read=1`, ApiResponses.Threads)
25633
25925
  ]);
25634
25926
  const lines = [];
25635
25927
  if (about.length) lines.push(`Unread about ${taskKey} (read one with the thread tool):`, ...about.map(formatThreadLine));
25636
- const elsewhere = mine.filter((thread) => !about.some((candidate) => candidate.id === thread.id));
25637
- if (elsewhere.length) lines.push("Unread in threads you are part of (answers to you):", ...elsewhere.map(formatThreadLine));
25638
25928
  if (general.length) lines.push("Unread general threads (context; nobody has to answer):", ...general.map(formatThreadLine));
25639
25929
  return lines.length ? lines.join("\n") : "Nothing unread.";
25640
25930
  }
25641
- async function persistMissedPushes(api, projectId, taskKey, cwd) {
25642
- const [mine, about] = await Promise.all([
25643
- api.get(`/projects/${projectId}/threads?participant=1&unread=1`, ApiResponses.Threads),
25644
- taskKey ? api.get(`/projects/${projectId}/threads?task=${encodeURIComponent(taskKey)}&unread=1`, ApiResponses.Threads) : Promise.resolve([])
25645
- ]);
25646
- const seen = /* @__PURE__ */ new Set();
25647
- for (const thread of [...about, ...mine]) {
25648
- if (seen.has(thread.id) || thread.unread === 0) continue;
25649
- seen.add(thread.id);
25650
- const detail = await api.get(`/projects/${projectId}/threads/${thread.id}?read=0`, ApiResponses.ThreadDetail);
25651
- for (const message of detail.messages.slice(-thread.unread)) persistInboxPush(cwd, { thread: { id: detail.id, subject: detail.subject, taskKey: detail.taskKey, to: detail.to.length ? detail.to.map((recipient) => recipient.agent).join(", ") : null }, message });
25931
+ async function fetchProjectsOverview(api) {
25932
+ const list = await api.get("/projects", ApiResponses.Projects);
25933
+ let linked = null;
25934
+ try {
25935
+ linked = (await resolveProject(api)).key;
25936
+ } catch {
25652
25937
  }
25938
+ return list.map((project) => `${project.key === linked ? "* " : " "}${project.key} ${project.name} (${project.openTaskCount} open)`).join("\n") + (linked ? "" : "\n\nThis repository is not linked: slop link <KEY>");
25653
25939
  }
25654
- function buildToolHandlers() {
25655
- const api = buildAuthedApiClient();
25656
- const resolveProjectFromArgs = (args) => resolveProject(api, args.project);
25940
+ function createProjectTools(api, project, identity) {
25941
+ api.sessionId = null;
25942
+ const resolveProjectFromArgs = async (_args) => project;
25943
+ const owner = { baseUrl: api.baseUrl, projectId: project.id, conversation: identity.conversation };
25944
+ const drainOwnInbox = (thread = null) => drainInbox(process.cwd(), owner, (push) => !thread || push.thread.id === thread);
25945
+ const persistOwnInboxPush = (push) => persistInboxPush(process.cwd(), push, owner);
25657
25946
  let held = null;
25658
- let identity = null;
25659
- const resolveOwnAgentIdentity = () => identity ??= resolveAgentIdentity(process.cwd());
25660
- const formatWakeMessage = (pushes) => `${pushes.length > 1 ? `${pushes.length} messages` : "A message"} for you on Slopware Tasks. ${pushes.length > 1 ? "They" : "It"} waited for your turn and others may have followed: read the thread (threads read) if it may be stale, act, and answer once (say reply) only if something is asked that you have not given yet. Do not acknowledge. Then carry on.
25661
-
25662
- ${pushes.map(formatPushBlock).join("\n\n")}`;
25663
- const resolveCodexSessionRoots = () => {
25664
- const own2 = resolveOwnHarnessHome("CODEX_HOME");
25665
- return (own2 ? [own2, ...fetchCodexHomes()] : fetchCodexHomes()).map((home) => join12(home, "sessions"));
25666
- };
25667
- let flushing = false;
25668
- const wakeCodexWithInboxWhenIdle = (conversation) => {
25669
- if (flushing) return;
25670
- flushing = true;
25671
- const wakeWhenTurnIdle = () => {
25672
- if (!hasInboxPushes(process.cwd())) {
25673
- flushing = false;
25674
- return;
25675
- }
25676
- if (fetchCodexTurnState(resolveCodexSessionRoots(), conversation) === "busy") {
25677
- setTimeout(wakeWhenTurnIdle, 2e3).unref();
25678
- return;
25679
- }
25680
- flushing = false;
25681
- const pushes = drainInbox(process.cwd());
25682
- if (pushes.length === 0) return;
25683
- void wakeCodex(formatWakeMessage(pushes), conversation, { binary: resolveCodexBinary(), codexHome: resolveOwnHarnessHome("CODEX_HOME") }).then((woken) => {
25684
- if (!woken) for (const push of pushes) persistInboxPush(process.cwd(), push);
25685
- });
25686
- };
25687
- setTimeout(wakeWhenTurnIdle, 2e3).unref();
25688
- };
25947
+ let stopped = false;
25948
+ const recovery = new SessionRecovery(api, owner, process.cwd(), (push) => held?.deliverPush(push));
25949
+ let waking = false;
25950
+ const resolveOwnAgentIdentity = () => identity;
25689
25951
  const holdSession = async (projectId, taskKey) => {
25690
- if (held && held.projectId === projectId && held.taskKey === taskKey) return;
25952
+ if (stopped) throw new Error("The project session is stopping");
25953
+ if (held && !held.isStopped && held.projectId === projectId && held.taskKey === taskKey) {
25954
+ await held.requireSession();
25955
+ return;
25956
+ }
25957
+ await recovery.syncCursor();
25958
+ if (stopped) throw new Error("The project session is stopping");
25959
+ if (held && !held.isStopped && held.taskKey === taskKey) {
25960
+ await held.requireSession();
25961
+ return;
25962
+ }
25691
25963
  held?.stop();
25692
25964
  held = new HeldSession(api, projectId, taskKey, {
25693
- // Claude Code is woken over its session socket: the message arrives as a turn, now, idle or not (mid-turn it
25694
- // rides the next tool result). Codex idle: `codex queue` to its thread starts a turn there. Codex mid-turn (its
25695
- // rollout says so): the queue would only be read when the turn ends, however long that is, so the message goes
25696
- // to the inbox and the PostToolUse hook hands it over after the next tool call; if the turn ends first, the
25697
- // inbox is queued then, as one message. When nothing can be done the inbox rides the chat's next slop tool
25698
- // result. Nothing holds a turn open.
25699
- onMessage: (push) => {
25965
+ onPush: persistOwnInboxPush,
25966
+ onMessage: () => {
25967
+ if (waking) return;
25968
+ waking = true;
25969
+ const text = `Messages are waiting for you on Slopware Tasks in project ${project.key}. Use session check with project ${project.id} to read them, then act or reply only if it changes something. Do not acknowledge.`;
25700
25970
  const me = resolveOwnAgentIdentity();
25701
- if (me.harness === "codex" && me.conversation && fetchCodexTurnState(resolveCodexSessionRoots(), me.conversation) === "busy") {
25702
- persistInboxPush(process.cwd(), push);
25703
- wakeCodexWithInboxWhenIdle(me.conversation);
25704
- return;
25705
- }
25706
- const wake = me.harness === "codex" && me.conversation ? wakeCodex(formatWakeMessage([push]), me.conversation, { binary: resolveCodexBinary(), codexHome: resolveOwnHarnessHome("CODEX_HOME") }) : wakeHarness(formatWakeMessage([push]));
25707
- void wake.then((woken) => {
25708
- if (!woken) persistInboxPush(process.cwd(), push);
25971
+ const wake = me.harness === "codex" && me.conversation ? wakeCodex(text, me.conversation, { binary: resolveCodexBinary(), codexHome: resolveOwnHarnessHome("CODEX_HOME") }) : me.harness === "claude" ? wakeHarness(text, void 0, me.conversation) : Promise.resolve(false);
25972
+ void wake.catch(() => false).finally(() => {
25973
+ waking = false;
25709
25974
  });
25710
25975
  },
25711
25976
  claim: resolveOwnAgentIdentity,
25712
- onReconnect: (session) => persistMissedPushes(api, projectId, session.taskKey, process.cwd()).catch(() => void 0)
25977
+ onReconnect: () => recovery.sync()
25713
25978
  });
25714
- await held.start();
25979
+ const current = held;
25980
+ await current.start();
25981
+ await current.requireSession();
25982
+ await recovery.sync();
25715
25983
  };
25716
25984
  const resolveProjectWithPresence = async (args) => {
25717
25985
  const target = await resolveProjectFromArgs(args);
25718
- if (!held) await holdSession(target.id, null);
25719
- await held?.ready;
25986
+ if (!held || held.isStopped) await holdSession(target.id, null);
25987
+ await held?.requireSession();
25720
25988
  return target;
25721
25989
  };
25722
25990
  const releaseTaskSession = async () => {
25723
25991
  if (held?.taskKey) await holdSession(held.projectId, null);
25724
25992
  };
25725
- if (!isNonInteractiveHarness())
25726
- void (async () => {
25727
- for (let waited = 0; waited < 1e4 && !fetchKnownConversation(process.cwd()); waited += 250) await new Promise((resolve5) => setTimeout(resolve5, 250));
25728
- resolveOwnAgentIdentity();
25729
- const target = await resolveProjectFromArgs({});
25730
- if (!held) await holdSession(target.id, null);
25731
- })().catch(() => void 0);
25732
- if (!isNonInteractiveHarness()) void trustCodexHooksForOwnHome().catch(() => void 0);
25733
- const endSessionAndExit = () => {
25734
- held?.stop();
25735
- held = null;
25736
- setTimeout(() => process.exit(0), 300).unref();
25737
- };
25738
- process.stdin.once("end", endSessionAndExit);
25739
- for (const signal of ["SIGTERM", "SIGHUP", "SIGINT"]) process.once(signal, endSessionAndExit);
25740
25993
  const waitForNextMessage = async (thread, minutes) => {
25741
- const queued = drainInbox(process.cwd());
25742
- const wanted = queued.filter((push) => !thread || push.thread.id === thread);
25743
- for (const other of queued) if (!wanted.includes(other)) persistInboxPush(process.cwd(), other);
25994
+ const wanted = drainOwnInbox(thread);
25744
25995
  if (wanted.length) return { pushes: wanted, released: false };
25745
- return new Promise((resolve5) => {
25996
+ const waitingOn = held;
25997
+ if (!waitingOn || waitingOn.isStopped || !waitingOn.session?.reachable) return { pushes: [], released: true };
25998
+ return new Promise((resolve6) => {
25746
25999
  const acceptDeliveredPush = (delivered) => {
25747
26000
  if (delivered && thread && delivered.thread.id !== thread) return false;
25748
- held?.waiters.delete(acceptDeliveredPush);
26001
+ if (delivered && drainInbox(process.cwd(), owner, (push) => push.message.id === delivered.message.id).length === 0) return false;
26002
+ waitingOn.waiters.delete(acceptDeliveredPush);
25749
26003
  clearTimeout(timer);
25750
- resolve5({ pushes: delivered ? [delivered] : [], released: delivered === null });
26004
+ resolve6({ pushes: delivered ? [delivered] : [], released: delivered === null });
25751
26005
  return true;
25752
26006
  };
25753
- held?.waiters.add(acceptDeliveredPush);
26007
+ waitingOn.waiters.add(acceptDeliveredPush);
25754
26008
  const timer = setTimeout(() => {
25755
- held?.waiters.delete(acceptDeliveredPush);
25756
- resolve5({ pushes: [], released: false });
26009
+ waitingOn.waiters.delete(acceptDeliveredPush);
26010
+ resolve6({ pushes: [], released: false });
25757
26011
  }, minutes * 6e4);
25758
26012
  });
25759
26013
  };
25760
26014
  const formatWaitOutcome = (outcome, minutes, thread) => {
25761
26015
  if (outcome.pushes.length) return outcome.pushes.map(formatPushBlock).join("\n\n");
25762
- if (outcome.released) return "Your person made you not visible to teammates: nothing can reach you now. You may stop.";
26016
+ if (outcome.released) return "This wait ended because the session was released, replaced, or made unreachable.";
25763
26017
  return thread ? `No reply in ${thread} in ${minutes} minutes.` : `No message in ${minutes} minutes.`;
25764
26018
  };
25765
- return [
26019
+ const tools = [
25766
26020
  {
25767
26021
  name: "wait_for_message",
25768
26022
  description: "Claude Code only, and rarely: block until the next message for you (on your task, in a thread you are in, or addressed to you), or, with `thread`, the next one in that thread; or until `minutes` pass. Messages reach you on their own; use this only when you cannot go on without the answer. Never on Codex: end your turn and the reply is your next turn.",
@@ -25781,7 +26035,7 @@ ${pushes.map(formatPushBlock).join("\n\n")}`;
25781
26035
  inputSchema: buildToolSchema({ task: buildStringSchema("Task key"), project: PROJECT }),
25782
26036
  run: async (args) => {
25783
26037
  const target = await resolveProjectWithPresence(args);
25784
- const queued = drainInbox(process.cwd()).map(formatPushBlock);
26038
+ const queued = drainOwnInbox().map(formatPushBlock);
25785
26039
  const rest = await buildUnreadDigest(api, target.id, args.task ?? null);
25786
26040
  return [...queued, rest].join("\n\n");
25787
26041
  }
@@ -26070,21 +26324,6 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
26070
26324
  return members2.map((member) => `${member.name} ${member.email} ${member.role}`).join("\n");
26071
26325
  }
26072
26326
  },
26073
- {
26074
- name: "projects",
26075
- description: "Every project you can reach, and which one this repository is linked to.",
26076
- inputSchema: buildToolSchema({}),
26077
- run: async () => {
26078
- const list = await api.get("/projects", ApiResponses.Projects);
26079
- let linked = null;
26080
- try {
26081
- linked = (await resolveProjectFromArgs({})).key;
26082
- } catch {
26083
- linked = null;
26084
- }
26085
- 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>");
26086
- }
26087
- },
26088
26327
  {
26089
26328
  name: "finish_task",
26090
26329
  description: "Move a task to the first done column.",
@@ -26097,17 +26336,33 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
26097
26336
  }
26098
26337
  }
26099
26338
  ];
26339
+ return {
26340
+ start: () => holdSession(project.id, null),
26341
+ stop: () => {
26342
+ stopped = true;
26343
+ recovery.stop();
26344
+ held?.stop();
26345
+ },
26346
+ tools: tools.map((tool) => ({ ...tool, run: async (args) => {
26347
+ const result = truncateToolResult(await tool.run(args));
26348
+ const arrived = tool.name === "check_messages" || tool.name === "wait_for_message" ? [] : drainOwnInbox();
26349
+ return arrived.length ? `${result}
26350
+
26351
+ --- Messages arrived while you worked (answer what is still open, once; do not acknowledge) ---
26352
+ ${arrived.map(formatPushBlock).join("\n\n")}` : result;
26353
+ } }))
26354
+ };
26100
26355
  }
26101
26356
  function fetchJsonFile(path2) {
26102
26357
  try {
26103
- return JSON.parse(readFileSync13(path2, "utf8"));
26358
+ return JSON.parse(readFileSync14(path2, "utf8"));
26104
26359
  } catch {
26105
26360
  return {};
26106
26361
  }
26107
26362
  }
26108
26363
  function installIntoClaudeHome(dir) {
26109
26364
  const done = [];
26110
- const primary = dir === join12(homedir4(), ".claude");
26365
+ const primary = dir === join13(homedir4(), ".claude");
26111
26366
  const env = { ...process.env };
26112
26367
  if (primary) delete env.CLAUDE_CONFIG_DIR;
26113
26368
  else env.CLAUDE_CONFIG_DIR = dir;
@@ -26120,7 +26375,7 @@ function installIntoClaudeHome(dir) {
26120
26375
  if (/already exists/i.test(said)) done.push(`Claude Code: MCP server "slop" already registered (user scope${where})`);
26121
26376
  else done.push(`Claude Code: run \`${primary ? "" : `CLAUDE_CONFIG_DIR=${dir} `}claude mcp add --scope user slop -- slop mcp\` (${said.trim() || "the claude command was not found here"})`);
26122
26377
  }
26123
- const path2 = join12(dir, "settings.json");
26378
+ const path2 = join13(dir, "settings.json");
26124
26379
  mkdirSync7(dir, { recursive: true });
26125
26380
  const settings = fetchJsonFile(path2);
26126
26381
  settings.hooks = mergeHooks(settings.hooks ?? {}, "claude");
@@ -26144,14 +26399,13 @@ function mergeHooks(hooks, harness) {
26144
26399
  hooks[event] = entries;
26145
26400
  };
26146
26401
  installHookForEvent("SessionStart", "start", 5, "Slopware Tasks");
26147
- if (harness === "codex") installHookForEvent("PostToolUse", "tool", 10, "Slopware Tasks");
26148
26402
  return hooks;
26149
26403
  }
26150
26404
  function installIntoCodexHome(dir) {
26151
26405
  const done = [];
26152
26406
  mkdirSync7(dir, { recursive: true });
26153
- const configTomlPath = join12(dir, "config.toml");
26154
- const current = existsSync10(configTomlPath) ? readFileSync13(configTomlPath, "utf8") : "";
26407
+ const configTomlPath = join13(dir, "config.toml");
26408
+ const current = existsSync9(configTomlPath) ? readFileSync14(configTomlPath, "utf8") : "";
26155
26409
  if (/^\[mcp_servers\.slop\]/m.test(current)) {
26156
26410
  if (/tool_timeout_sec = 1800\b/.test(current)) writeFileSync8(configTomlPath, current.replace(/tool_timeout_sec = 1800\b/, "tool_timeout_sec = 21600"));
26157
26411
  done.push(`Codex: MCP server already in ${configTomlPath}`);
@@ -26163,17 +26417,17 @@ tool_timeout_sec = 21600
26163
26417
  `);
26164
26418
  done.push(`Codex: MCP server "slop" added to ${configTomlPath}`);
26165
26419
  }
26166
- const hooksPath = join12(dir, "hooks.json");
26420
+ const hooksPath = join13(dir, "hooks.json");
26167
26421
  const file2 = fetchJsonFile(hooksPath);
26168
26422
  file2.hooks = mergeHooks(file2.hooks ?? {}, "codex");
26169
26423
  writeFileSync8(hooksPath, `${JSON.stringify(file2, null, 2)}
26170
26424
  `);
26171
- done.push(`Codex: SessionStart and PostToolUse hooks written to ${hooksPath}; other slop hooks removed (no Codex hook holds a turn open)`);
26425
+ done.push(`Codex: SessionStart hook written to ${hooksPath}; other slop hooks removed (Codex is woken with codex queue; no hook holds a turn open)`);
26172
26426
  return done;
26173
26427
  }
26174
26428
  async function trustCodexHooks(codexHome) {
26175
26429
  const server = spawn3("codex", ["app-server", "-c", "mcp_servers={}"], { stdio: ["pipe", "pipe", "ignore"], env: { ...process.env, CODEX_HOME: codexHome } });
26176
- const where = codexHome === join12(homedir4(), ".codex") ? "" : ` under ${codexHome}`;
26430
+ const where = codexHome === join13(homedir4(), ".codex") ? "" : ` under ${codexHome}`;
26177
26431
  let buffer = "";
26178
26432
  const pending = /* @__PURE__ */ new Map();
26179
26433
  server.stdout.on("data", (chunk) => {
@@ -26191,7 +26445,7 @@ async function trustCodexHooks(codexHome) {
26191
26445
  }
26192
26446
  });
26193
26447
  let nextId = 1;
26194
- const callCodexMethod = (method, params) => new Promise((resolve5, reject) => {
26448
+ const callCodexMethod = (method, params) => new Promise((resolve6, reject) => {
26195
26449
  const id = nextId++;
26196
26450
  const timer = setTimeout(() => {
26197
26451
  pending.delete(id);
@@ -26200,7 +26454,7 @@ async function trustCodexHooks(codexHome) {
26200
26454
  pending.set(id, (message) => {
26201
26455
  clearTimeout(timer);
26202
26456
  if (message.error) reject(new Error(message.error.message ?? `${method} failed`));
26203
- else resolve5(message.result);
26457
+ else resolve6(message.result);
26204
26458
  });
26205
26459
  server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
26206
26460
  `);
@@ -26208,7 +26462,7 @@ async function trustCodexHooks(codexHome) {
26208
26462
  const sendNotification = (method, params) => server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}
26209
26463
  `);
26210
26464
  try {
26211
- await callCodexMethod("initialize", { clientInfo: { name: "slop", version: "0.4.7" }, capabilities: { experimentalApi: true } });
26465
+ await callCodexMethod("initialize", { clientInfo: { name: "slop", version: "0.4.9" }, capabilities: { experimentalApi: true } });
26212
26466
  sendNotification("initialized", {});
26213
26467
  const listed = await callCodexMethod("hooks/list", { cwds: [process.cwd()] });
26214
26468
  const ours = listed.data.flatMap((entry) => entry.hooks).filter((hook) => hook.command?.startsWith("slop hook "));
@@ -26224,22 +26478,12 @@ async function trustCodexHooks(codexHome) {
26224
26478
  server.kill();
26225
26479
  }
26226
26480
  }
26227
- async function trustCodexHooksForOwnHome() {
26228
- const home = resolveOwnHarnessHome("CODEX_HOME");
26229
- if (!home) return;
26230
- const hooksPath = join12(home, "hooks.json");
26231
- const configTomlPath = join12(home, "config.toml");
26232
- if (!existsSync10(hooksPath) || !existsSync10(configTomlPath)) return;
26233
- if (!readFileSync13(hooksPath, "utf8").includes("slop hook stop")) return;
26234
- if (readFileSync13(configTomlPath, "utf8").includes(`hooks.state."${hooksPath}:stop:`)) return;
26235
- await trustCodexHooks(home);
26236
- }
26237
26481
  async function parseHookInput(harness, remember = true) {
26238
- const input2 = await new Promise((resolve5) => {
26482
+ const input2 = await new Promise((resolve6) => {
26239
26483
  let data = "";
26240
26484
  process.stdin.on("data", (chunk) => data += chunk);
26241
- process.stdin.on("end", () => resolve5(data));
26242
- setTimeout(() => resolve5(data), 2e3).unref();
26485
+ process.stdin.on("end", () => resolve6(data));
26486
+ setTimeout(() => resolve6(data), 2e3).unref();
26243
26487
  });
26244
26488
  let cwd = process.cwd();
26245
26489
  let conversation = null;
@@ -26249,7 +26493,7 @@ async function parseHookInput(harness, remember = true) {
26249
26493
  conversation = parsed.session_id ?? null;
26250
26494
  } catch {
26251
26495
  }
26252
- const ownThread = harness !== "codex" || !conversation || isMainChatThread(fetchCodexHomes().map((home) => join12(home, "sessions")), conversation);
26496
+ const ownThread = harness !== "codex" || !conversation || isMainChatThread(fetchCodexHomes().map((home) => join13(home, "sessions")), conversation);
26253
26497
  if (remember && conversation && ownThread && !isNonInteractiveHarness()) persistRememberedConversation(cwd, { id: conversation, harness: harness ?? "other" });
26254
26498
  return { cwd, conversation };
26255
26499
  }
@@ -26257,17 +26501,7 @@ async function runPromptHook(harness) {
26257
26501
  await parseHookInput(harness);
26258
26502
  }
26259
26503
  async function runToolHook(harness) {
26260
- if (isNonInteractiveHarness()) return;
26261
- const { cwd, conversation } = await parseHookInput(harness, false);
26262
- const known = fetchKnownConversation(cwd);
26263
- if (known && conversation && known.id !== conversation) return;
26264
- const arrived = drainInbox(cwd);
26265
- if (arrived.length === 0) return;
26266
- const text = `Messages arrived on Slopware Tasks while you worked (answer what is still open, once, with say reply; do not acknowledge):
26267
-
26268
- ${arrived.map(formatPushBlock).join("\n\n")}`;
26269
- process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } })}
26270
- `);
26504
+ await parseHookInput(harness, false);
26271
26505
  }
26272
26506
  async function runStartHook(harness) {
26273
26507
  if (isNonInteractiveHarness()) return;
@@ -26311,12 +26545,39 @@ ${parts.join("\n")}` })}
26311
26545
  }
26312
26546
  function buildTools() {
26313
26547
  if (isNonInteractiveHarness()) return [];
26314
- const fine = buildToolHandlers();
26315
- const runHandlerByName = (name, args) => {
26316
- const handler = fine.find((candidate) => candidate.name === name);
26317
- if (!handler) throw new Error(`No such action`);
26548
+ const api = buildAuthedApiClient();
26549
+ let identity = null;
26550
+ let stopped = false;
26551
+ const projects = /* @__PURE__ */ new Map();
26552
+ const requireProjectTools = (project) => {
26553
+ if (stopped) throw new Error("The MCP server is stopping");
26554
+ let context = projects.get(project.id);
26555
+ if (!context) {
26556
+ identity ??= resolveAgentIdentity(process.cwd());
26557
+ context = createProjectTools(buildAuthedApiClient(), project, identity);
26558
+ projects.set(project.id, context);
26559
+ }
26560
+ return context;
26561
+ };
26562
+ const runHandlerByName = async (name, args) => {
26563
+ if (name === "projects") return fetchProjectsOverview(api);
26564
+ const project = await resolveProject(api, args.project);
26565
+ const handler = requireProjectTools(project).tools.find((candidate) => candidate.name === name);
26566
+ if (!handler) throw new Error("No such action");
26318
26567
  return handler.run(args);
26319
26568
  };
26569
+ void (async () => {
26570
+ for (let waited = 0; waited < 1e4 && !fetchKnownConversation(process.cwd()) && !stopped; waited += 250) await new Promise((resolve6) => setTimeout(resolve6, 250));
26571
+ if (stopped) return;
26572
+ await requireProjectTools(await resolveProject(api)).start();
26573
+ })().catch(() => void 0);
26574
+ const endSessionAndExit = () => {
26575
+ stopped = true;
26576
+ for (const context of projects.values()) context.stop();
26577
+ setTimeout(() => process.exit(0), 300).unref();
26578
+ };
26579
+ process.stdin.once("end", endSessionAndExit);
26580
+ for (const signal of ["SIGTERM", "SIGHUP", "SIGINT"]) process.once(signal, endSessionAndExit);
26320
26581
  const buildGroupedTool = (name, description2, actions, properties) => ({
26321
26582
  name,
26322
26583
  description: description2,
@@ -26361,17 +26622,12 @@ function buildTools() {
26361
26622
  )
26362
26623
  ];
26363
26624
  }
26364
- function attachInboxToTools(list, version2) {
26625
+ function attachUpdateNoticeToTools(list, version2) {
26365
26626
  return list.map((tool) => ({
26366
26627
  ...tool,
26367
26628
  run: async (args) => {
26368
26629
  const result = truncateToolResult(await tool.run(args));
26369
26630
  const parts = [result];
26370
- if (!(tool.name === "session" && (args.action === "check" || args.action === "wait"))) {
26371
- const arrived = drainInbox(process.cwd());
26372
- if (arrived.length) parts.push(`--- Messages arrived while you worked (answer what is still open, once; do not acknowledge) ---
26373
- ${arrived.map(formatPushBlock).join("\n\n")}`);
26374
- }
26375
26631
  const notice = isUpdateCheckEnabled() ? buildUpdateNotice(version2) : null;
26376
26632
  if (notice) parts.push(notice);
26377
26633
  return parts.join("\n\n");
@@ -26380,7 +26636,7 @@ ${arrived.map(formatPushBlock).join("\n\n")}`);
26380
26636
  }
26381
26637
  function registerMcpCommands(program3, version2) {
26382
26638
  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.");
26383
- mcp.command("serve", { isDefault: true }).description("Serve MCP over stdio (what the harness runs).").action(() => serveMcp(attachInboxToTools(buildTools(), version2), { name: "slop", version: version2 }));
26639
+ mcp.command("serve", { isDefault: true }).description("Serve MCP over stdio (what the harness runs).").action(() => serveMcp(attachUpdateNoticeToTools(buildTools(), version2), { name: "slop", version: version2 }));
26384
26640
  mcp.command("install").description("Register the server and the two hooks with Claude Code and Codex on this machine.").action(async () => {
26385
26641
  const homes = fetchCodexHomes();
26386
26642
  const lines = [...fetchClaudeHomes().flatMap((home) => installIntoClaudeHome(home)), ...homes.flatMap((home) => installIntoCodexHome(home))];
@@ -26396,16 +26652,15 @@ function registerMcpCommands(program3, version2) {
26396
26652
  const hook = program3.command("hook").description("Harness hooks (installed by slop mcp install).");
26397
26653
  const parseHarnessFromOption = (options) => options.harness === "claude" || options.harness === "codex" ? options.harness : options.harness ? "other" : null;
26398
26654
  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 and how messages work.").option("--harness <name>", "claude or codex").action((options) => runStartHook(parseHarnessFromOption(options)));
26399
- hook.command("tool").description("PostToolUse hook (Codex): hands the model what reached the inbox while its turn ran, with the tool's result.").option("--harness <name>", "claude or codex").action((options) => runToolHook(parseHarnessFromOption(options)));
26655
+ hook.command("tool").description("No longer installed; kept so an older install's hook exits cleanly.").option("--harness <name>", "claude or codex").action((options) => runToolHook(parseHarnessFromOption(options)));
26400
26656
  hook.command("prompt").description("No longer installed; kept so an older install's hook exits cleanly.").option("--harness <name>", "claude or codex").action((options) => runPromptHook(parseHarnessFromOption(options)));
26401
26657
  hook.command("stop").description("No longer installed (Claude Code is woken over its socket, Codex through codex queue); kept so an older install's hook still hands over unread messages.").option("--harness <name>", "claude or codex").action((options) => runStopHook(parseHarnessFromOption(options)));
26402
26658
  }
26403
26659
 
26404
26660
  // src/commands/sessions.ts
26405
- import { readFileSync as readFileSync14 } from "fs";
26406
26661
  var buildTaskPath2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
26407
26662
  var formatSessionLine2 = (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"];
26408
- function registerSessionCommands(program3, task) {
26663
+ function registerSessionCommands(program3) {
26409
26664
  const registerProjectOption = (command) => command.option("-p, --project <key>", "project key (default: the project this repository is linked to)");
26410
26665
  const session = program3.command("session").description("Claim a task and be reachable on it while you work.");
26411
26666
  registerProjectOption(session.command("start <key>")).description("Claim a task: assign it to you, move it to the first in-progress column, then tell you how to go live.").action(async (key, options) => {
@@ -26487,13 +26742,13 @@ ${written.map((path2) => ` ${path2}`).join("\n")}`);
26487
26742
  }
26488
26743
 
26489
26744
  // src/index.ts
26490
- 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.7").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => updateJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
26745
+ 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.9").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => updateJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
26491
26746
  registerAuthCommands(program2);
26492
26747
  registerProjectCommands(program2);
26493
26748
  var taskCommand = registerTaskCommands(program2);
26494
- registerSessionCommands(program2, taskCommand);
26749
+ registerSessionCommands(program2);
26495
26750
  registerThreadCommands(program2, taskCommand);
26496
- registerMcpCommands(program2, "0.4.7");
26751
+ registerMcpCommands(program2, "0.4.9");
26497
26752
  registerGuideCommand(program2);
26498
26753
  registerSkillCommands(program2);
26499
26754
  program2.parseAsync().catch(exitWithError);