slopwaresystems 0.4.1 → 0.4.3

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 +124 -133
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3385,7 +3385,7 @@ var slop_cli_default = "---\nname: slop-cli\ndescription: How an agent works the
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 `slop session wait` background task finishes, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is you, live. With the MCP server it exists from your first slop tool call until your harness exits: first as presence in the project (no task), then on the task you claim. Being live means messages find you. Sessions are visible to everyone (`slop sessions`, the board's live badges) and are advisory: they say who is working, they do not lock anything.\n\n**Threads** are how agents and people talk across sessions. A thread has a subject, a scope (one task, or general) and messages; it is stored, so nothing is lost when a session ends. **The task is the address:** to reach the agent on ST-3, you write in a thread about ST-3. General threads are for people and for context; nobody claims them and you never have to answer one.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, or in **a thread you have written in** (that is how answers find you, even from a general thread or another ticket), reaches you at once. Nothing else does.\n- **Everything else waits.** `slop session start <KEY>` prints the unread threads about that task and the unread general ones (read those for context). `slop threads` lists them any time; `slop thread show <id>` reads one.\n- Your own messages never wake your own waiter.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `session` `claim` with the task. Assigns the task to you, moves it to in progress, and returns what is unread about it. Read that first.\n2. **Work.** Keep the plan's notes current with `plan` `note`, tick with `plan` `done`.\n3. **You are live from your first call.** The server holds presence in the project as soon as you use any tool, and `session` `claim` upgrades it to the task; `task` `finish` or `session` `release` drops back to presence. Either way, for as long as the harness runs the server A message in a thread about your task, or in any thread you have written in, reaches you on its own: right after your next tool call (any tool; a hook on Claude Code and on Codex hands it to you), and in every slop tool's result besides. There is nothing to poll and nothing to restart. When one arrives, act or answer with `say` `reply` and carry on.\n4. **Nothing to do but wait?** `session` `wait` blocks until the next message. Optional; most of the time you just keep working.\n5. **Finish:** `task` `finish` (it also releases the session), or `session` `release` to step off without finishing.\n\nBoth harnesses also refuse to stop while messages are unread about your task (a Stop hook), so nothing is lost at the end of a turn.\n\n## Without MCP (scripts, people at a shell)\n\n`slop session start <KEY>` then `slop session wait <KEY>` in the background: it prints the first message and exits, and you run it again. Only for setups with no MCP. The tools above are the way.\n\n## Talking\n\n```\nsession who every live agent: name, task, person, machine, where it runs; you are marked\nthreads list (task?, unread?) list; threads read (id) reads one (marks it read)\nsay reply (thread, body) reaches the agents that wrote in the thread, the task's live agents, and its addressee\nsay post (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) 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**Messages find you.** Anything for you (about your task, in a thread you are in, addressed to you) arrives on its own. On Claude Code it arrives as a turn of yours, even while you are idle. On Codex, when your turn ends the Stop hook waits on your behalf, costing nothing, and continues your turn the moment a message arrives; your person typing comes first and you wait again after. Otherwise it arrives with your next tool result. Act or answer it (`say` `reply`), then carry on. If your person makes you not visible to teammates, nothing reaches you and you cannot post or reply until they switch it back.\n\nYour session survives a bad network and a deploy: the server keeps it for two minutes and you reconnect as the same agent; anything said in the gap is handed to you after your next tool call.\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan's notes, decisions in the description.\n\n## Etiquette\n\n- One live session per agent, and one agent per working directory: run each agent in its own checkout or git worktree, so its inbox is its own.\n- Do not claim a task that has someone else's live session (`session` `who`); write in a thread about it first.\n- Answer messages about your task promptly: the other agent is blocked on you.\n- Report progress in the plan's notes, not in threads. Threads are for talking to someone.\n- If you stop working on a task, end the session so nobody waits on you.\n";
3388
+ var slop_sessions_default = "---\nname: slop-sessions\ndescription: How an agent claims a task, stays reachable on it (a live session), sees who else is live, and talks to other agents and people through the project's message board. Load when starting work on a task, when asked to check on other agents, when a `slop session wait` background task finishes, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is you, live. With the MCP server it exists from your first slop tool call until your harness exits: first as presence in the project (no task), then on the task you claim. Being live means messages find you. Sessions are visible to everyone (`slop sessions`, the board's live badges) and are advisory: they say who is working, they do not lock anything.\n\n**Threads** are how agents and people talk across sessions. A thread has a subject, a scope (one task, or general) and messages; it is stored, so nothing is lost when a session ends. **The task is the address:** to reach the agent on ST-3, you write in a thread about ST-3. General threads are for people and for context; nobody claims them and you never have to answer one.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, or in **a thread you have written in** (that is how answers find you, even from a general thread or another ticket), reaches you at once. Nothing else does.\n- **Everything else waits.** `slop session start <KEY>` prints the unread threads about that task and the unread general ones (read those for context). `slop threads` lists them any time; `slop thread show <id>` reads one.\n- Your own messages never wake your own waiter.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `session` `claim` with the task. Assigns the task to you, moves it to in progress, and returns what is unread about it. Read that first.\n2. **Work.** Keep the plan's notes current with `plan` `note`, tick with `plan` `done`.\n3. **You are live from your first call.** The server holds presence in the project as soon as you use any tool, and `session` `claim` upgrades it to the task; `task` `finish` or `session` `release` drops back to presence. Either way, for as long as the harness runs the server A message in a thread about your task, or in any thread you have written in, reaches you on its own: right after your next tool call (any tool; a hook on Claude Code and on Codex hands it to you), and in every slop tool's result besides. There is nothing to poll and nothing to restart. When one arrives, act or answer with `say` `reply` and carry on.\n4. **Nothing to do but wait?** `session` `wait` blocks until the next message. Optional; most of the time you just keep working.\n5. **Finish:** `task` `finish` (it also releases the session), or `session` `release` to step off without finishing.\n\nNothing is lost at the end of a turn: what arrived while you worked 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 its addressee\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) a general thread: for people, and for context\n (subject is optional: without one, the first line of the body)\nsay reply (thread, body, wait: 20) reply, then wait up to 20 minutes for the answer, in one call\nsession wait (thread, minutes) wait for the next message in that thread (20 minutes unless told)\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**Messages find you.** Anything for you (about your task, in a thread you are in, addressed to you) arrives on its own. On Claude Code it arrives as a turn of yours, even while you are idle. On Codex it waits for you: it is handed to you with your next tool result. So on Codex, when you are talking with someone and need their answer to go on, wait for it inside your turn: `say` `reply` (or `post`) with `wait: 20` returns their reply in the same call, or `session` `wait` with `thread` does the same for a thread you already wrote in. Nothing else to do and nobody at your keyboard: `session` `wait` alone blocks for the next message for you.\n\nYour session survives a bad network and a deploy: the server keeps it for two minutes and you reconnect as the same agent; anything said in the gap is handed to you after your next tool call.\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan's notes, decisions in the description.\n\n## Etiquette\n\n- One live session per agent, and one agent per working directory: run each agent in its own checkout or git worktree, so its inbox is its own.\n- Do not claim a task that has someone else's live session (`session` `who`); write in a thread about it first.\n- Answer messages about your task promptly: the other agent is blocked on you.\n- Report progress in the plan's notes, not in threads. Threads are for talking to someone.\n- If you stop working on a task, end the session so nobody waits on you.\n";
3389
3389
 
3390
3390
  // skills/slop-working.md
3391
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";
@@ -3398,11 +3398,11 @@ function installSkills({ force = false } = {}) {
3398
3398
  const written = [];
3399
3399
  for (const home of SKILL_HOMES) {
3400
3400
  for (const [name, markdown] of Object.entries(SKILLS)) {
3401
- const file3 = join(home, name, "SKILL.md");
3402
- if (!force && existsSync(file3) && readFileSync(file3, "utf8") === markdown) continue;
3401
+ const file2 = join(home, name, "SKILL.md");
3402
+ if (!force && existsSync(file2) && readFileSync(file2, "utf8") === markdown) continue;
3403
3403
  mkdirSync(join(home, name), { recursive: true });
3404
- writeFileSync(file3, markdown);
3405
- written.push(file3);
3404
+ writeFileSync(file2, markdown);
3405
+ written.push(file2);
3406
3406
  }
3407
3407
  }
3408
3408
  return written;
@@ -24147,14 +24147,15 @@ linked to ${link.projectKey} ${link.projectName} (${link.baseUrl})`
24147
24147
 
24148
24148
  // src/commands/mcp.ts
24149
24149
  import { execFileSync as execFileSync5, spawn as spawn3 } from "child_process";
24150
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync13, writeFileSync as writeFileSync8 } from "fs";
24150
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync13, writeFileSync as writeFileSync7 } from "fs";
24151
24151
  import "path";
24152
24152
  import { homedir as homedir4 } from "os";
24153
- import { join as join12 } from "path";
24153
+ import { join as join11 } from "path";
24154
24154
 
24155
24155
  // src/platform/live.ts
24156
24156
  var PING_EVERY_MS = 25e3;
24157
24157
  var PONG_WITHIN_MS = 1e4;
24158
+ var STANDBY_POLL_MS = 2e4;
24158
24159
  async function waitForMessage(api, projectId, taskKey, options = {}) {
24159
24160
  const ticket = await api.post(`/projects/${projectId}/tasks/${encodeURIComponent(taskKey)}/session/ticket`, options.claim ?? {});
24160
24161
  const socket = new WebSocket(`${api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
@@ -24213,7 +24214,10 @@ var HeldSession = class {
24213
24214
  attempt = 0;
24214
24215
  connected = 0;
24215
24216
  session = null;
24216
- /** Whoever is sitting in `session wait`. Handed the next message, or null when standby is switched off under them. */
24217
+ /**
24218
+ * Whoever is waiting for a message. Handed each one, and answers whether it took it (a waiter for one thread lets
24219
+ * the rest pass to the inbox); handed null when the agent is made unreachable under them.
24220
+ */
24217
24221
  waiters = /* @__PURE__ */ new Set();
24218
24222
  async start() {
24219
24223
  await this.connect();
@@ -24257,14 +24261,16 @@ var HeldSession = class {
24257
24261
  }
24258
24262
  if (push.type !== "thread") return;
24259
24263
  const delivered = { thread: push.thread, message: push.message };
24260
- const taken = this.waiters.size > 0;
24261
- for (const waiter of [...this.waiters]) waiter(delivered);
24264
+ let taken = false;
24265
+ for (const waiter of [...this.waiters]) if (waiter(delivered)) taken = true;
24262
24266
  if (!taken) this.hooks.onMessage(delivered);
24263
24267
  });
24264
- socket.addEventListener("close", () => {
24268
+ socket.addEventListener("close", (event) => {
24265
24269
  this.stopHeartbeat();
24266
24270
  if (this.socket === socket) this.socket = null;
24267
- if (!this.stopped) this.schedule();
24271
+ if (this.stopped) return;
24272
+ if (event.reason === "replaced") this.standBy();
24273
+ else this.schedule();
24268
24274
  });
24269
24275
  socket.addEventListener("error", () => {
24270
24276
  });
@@ -24296,10 +24302,23 @@ var HeldSession = class {
24296
24302
  this.attempt += 1;
24297
24303
  this.timer = setTimeout(() => void this.connect(), Math.min(3e4, 1e3 * 2 ** Math.min(this.attempt, 5)));
24298
24304
  }
24305
+ /** Another process holds this session now: look now and then, and take it back once it is no longer live. */
24306
+ standBy() {
24307
+ this.timer = setTimeout(async () => {
24308
+ if (this.stopped) return;
24309
+ let holder = true;
24310
+ try {
24311
+ const sessions = await this.api.get(`/projects/${this.projectId}/sessions`);
24312
+ holder = sessions.some((session) => session.id === this.session?.id && session.state === "live");
24313
+ } catch {
24314
+ }
24315
+ if (holder) this.standBy();
24316
+ else void this.connect();
24317
+ }, STANDBY_POLL_MS);
24318
+ }
24299
24319
  stop() {
24300
24320
  this.stopped = true;
24301
24321
  this.api.sessionId = null;
24302
- rememberSession(process.cwd(), null);
24303
24322
  if (this.timer) clearTimeout(this.timer);
24304
24323
  this.stopHeartbeat();
24305
24324
  this.socket?.close(1e3, "released");
@@ -24380,16 +24399,16 @@ var directoriesNamed = (root, prefix) => {
24380
24399
  return [];
24381
24400
  }
24382
24401
  };
24383
- var hasAny = (dir2, files) => files.some((file3) => existsSync6(join6(dir2, file3)));
24402
+ var hasAny = (dir, files) => files.some((file2) => existsSync6(join6(dir, file2)));
24384
24403
  function claudeHomes(root = homedir3(), inUse = homesInUse) {
24385
24404
  if (process.env.SLOP_CLAUDE_HOME) return [process.env.SLOP_CLAUDE_HOME];
24386
24405
  const named = process.env.CLAUDE_CONFIG_DIR ? [process.env.CLAUDE_CONFIG_DIR] : [];
24387
- return unique([join6(root, ".claude"), ...named, ...inUse("CLAUDE_CONFIG_DIR"), ...directoriesNamed(root, ".claude").filter((dir2) => hasAny(dir2, ["settings.json", ".claude.json", "sessions"]))]);
24406
+ return unique([join6(root, ".claude"), ...named, ...inUse("CLAUDE_CONFIG_DIR"), ...directoriesNamed(root, ".claude").filter((dir) => hasAny(dir, ["settings.json", ".claude.json", "sessions"]))]);
24388
24407
  }
24389
24408
  function codexHomes(root = homedir3(), inUse = homesInUse) {
24390
24409
  if (process.env.SLOP_CODEX_HOME) return [process.env.SLOP_CODEX_HOME];
24391
24410
  const named = process.env.CODEX_HOME ? [process.env.CODEX_HOME] : [];
24392
- return unique([join6(root, ".codex"), ...named, ...inUse("CODEX_HOME"), ...directoriesNamed(root, ".codex").filter((dir2) => hasAny(dir2, ["config.toml", "auth.json", "sessions"]))]);
24411
+ return unique([join6(root, ".codex"), ...named, ...inUse("CODEX_HOME"), ...directoriesNamed(root, ".codex").filter((dir) => hasAny(dir, ["config.toml", "auth.json", "sessions"]))]);
24393
24412
  }
24394
24413
  function ownHarnessHome(variable) {
24395
24414
  if (process.platform === "win32") return null;
@@ -24541,15 +24560,15 @@ var same = (a, b) => {
24541
24560
  };
24542
24561
  function codexConversation(roots, cwd) {
24543
24562
  const rollouts = [];
24544
- const walk = (dir2, depth) => {
24563
+ const walk = (dir, depth) => {
24545
24564
  let entries;
24546
24565
  try {
24547
- entries = readdirSync2(dir2, { withFileTypes: true });
24566
+ entries = readdirSync2(dir, { withFileTypes: true });
24548
24567
  } catch {
24549
24568
  return;
24550
24569
  }
24551
24570
  for (const entry of entries) {
24552
- const path3 = join8(dir2, entry.name);
24571
+ const path3 = join8(dir, entry.name);
24553
24572
  if (entry.isDirectory()) {
24554
24573
  if (depth < 3) walk(path3, depth + 1);
24555
24574
  continue;
@@ -24581,13 +24600,13 @@ function codexConversation(roots, cwd) {
24581
24600
  if (!line.includes('"session_meta"')) continue;
24582
24601
  const found = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(line);
24583
24602
  if (!found) continue;
24584
- let dir2;
24603
+ let dir;
24585
24604
  try {
24586
- dir2 = JSON.parse(`"${found[1]}"`);
24605
+ dir = JSON.parse(`"${found[1]}"`);
24587
24606
  } catch {
24588
24607
  continue;
24589
24608
  }
24590
- if (same(dir2, cwd)) return rollout.id;
24609
+ if (same(dir, cwd)) return rollout.id;
24591
24610
  }
24592
24611
  return null;
24593
24612
  }
@@ -25083,23 +25102,6 @@ function inboxDrain(cwd) {
25083
25102
  });
25084
25103
  }
25085
25104
 
25086
- // src/platform/wake.ts
25087
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, rmSync as rmSync5, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
25088
- import { dirname as dirname6, join as join11 } from "path";
25089
- var dir = (kind) => join11(dirname6(configPath()), kind);
25090
- var file2 = (kind, cwd) => join11(dir(kind), inboxKey(cwd));
25091
- function markWake(cwd) {
25092
- mkdirSync7(dir("wake"), { recursive: true });
25093
- writeFileSync7(file2("wake", cwd), String(Date.now()));
25094
- }
25095
- function takeWake(cwd, since) {
25096
- const path3 = file2("wake", cwd);
25097
- if (!existsSync10(path3)) return false;
25098
- const at = statSync4(path3).mtimeMs;
25099
- rmSync5(path3, { force: true });
25100
- return at >= since - 1e3;
25101
- }
25102
-
25103
25105
  // src/commands/mcp.ts
25104
25106
  var str = (description2) => ({ type: "string", description: description2 });
25105
25107
  var int2 = (description2) => ({ type: "integer", description: description2 });
@@ -25155,6 +25157,9 @@ function handlers() {
25155
25157
  held = new HeldSession(api, projectId, taskKey, {
25156
25158
  // A message for the agent wakes its harness when the harness can be woken (Claude Code's session socket): it
25157
25159
  // arrives as a turn, now, whether the agent is idle or mid-turn. Otherwise the inbox and the hooks carry it.
25160
+ // Claude Code is woken over its session socket: the message arrives as a turn, now, idle or not. Codex has no
25161
+ // such door (an app server named by SLOP_CODEX_APP_SERVER aside), so the message waits in the inbox and rides
25162
+ // the chat's next tool result. Nothing holds a turn open, nothing runs on the chat's behalf.
25158
25163
  onMessage: (push) => {
25159
25164
  const text = `A message for you on Slopware Tasks. Act or answer it (say reply), then carry on.
25160
25165
 
@@ -25166,11 +25171,7 @@ ${pushBlock(push)}`;
25166
25171
  });
25167
25172
  },
25168
25173
  claim: whoAmI,
25169
- onReconnect: (session) => catchUp(api, projectId, session.taskKey, process.cwd()).catch(() => void 0),
25170
- // Made unreachable: a Codex Stop hook holding a turn open has nothing to wait for; the marker lets it go.
25171
- onReachable: (on) => {
25172
- if (!on) markWake(process.cwd());
25173
- }
25174
+ onReconnect: (session) => catchUp(api, projectId, session.taskKey, process.cwd()).catch(() => void 0)
25174
25175
  });
25175
25176
  await held.start();
25176
25177
  };
@@ -25184,7 +25185,7 @@ ${pushBlock(push)}`;
25184
25185
  };
25185
25186
  if (!nonInteractiveHarness())
25186
25187
  void (async () => {
25187
- for (let waited = 0; waited < 1e4 && !knownConversation(process.cwd()); waited += 250) await new Promise((resolve5) => setTimeout(resolve5, 250));
25188
+ for (let waited2 = 0; waited2 < 1e4 && !knownConversation(process.cwd()); waited2 += 250) await new Promise((resolve5) => setTimeout(resolve5, 250));
25188
25189
  whoAmI();
25189
25190
  const target = await resolveOnly({});
25190
25191
  if (!held) await hold(target.id, null);
@@ -25197,32 +25198,42 @@ ${pushBlock(push)}`;
25197
25198
  };
25198
25199
  process.stdin.once("end", goodbye);
25199
25200
  for (const signal of ["SIGTERM", "SIGHUP", "SIGINT"]) process.once(signal, goodbye);
25201
+ const waitFor = async (thread, minutes) => {
25202
+ const queued = inboxDrain(process.cwd());
25203
+ const wanted = queued.filter((push) => !thread || push.thread.id === thread);
25204
+ for (const other of queued) if (!wanted.includes(other)) inboxWrite(process.cwd(), other);
25205
+ if (wanted.length) return { pushes: wanted, released: false };
25206
+ return new Promise((resolve5) => {
25207
+ const waiter = (delivered) => {
25208
+ if (delivered && thread && delivered.thread.id !== thread) return false;
25209
+ held?.waiters.delete(waiter);
25210
+ clearTimeout(timer);
25211
+ resolve5({ pushes: delivered ? [delivered] : [], released: delivered === null });
25212
+ return true;
25213
+ };
25214
+ held?.waiters.add(waiter);
25215
+ const timer = setTimeout(() => {
25216
+ held?.waiters.delete(waiter);
25217
+ resolve5({ pushes: [], released: false });
25218
+ }, minutes * 6e4);
25219
+ });
25220
+ };
25221
+ const waited = (outcome, minutes, thread) => {
25222
+ if (outcome.pushes.length) return outcome.pushes.map(pushBlock).join("\n\n");
25223
+ if (outcome.released) return "Your person made you not visible to teammates: nothing can reach you now. You may stop.";
25224
+ return thread ? `No reply in ${thread} in ${minutes} minutes.` : `No message in ${minutes} minutes.`;
25225
+ };
25200
25226
  return [
25201
25227
  {
25202
25228
  name: "wait_for_message",
25203
- description: "Block until the next message for you (on your task, in a thread you are in, or addressed to you), or until `minutes` pass. Optional: messages reach you on their own; use this only when you have nothing else to do and nobody is at your keyboard.",
25204
- inputSchema: schema({ task: str("Task key to go live on first (optional)"), minutes: int2("Give up after (default 300)"), project: PROJECT }),
25229
+ description: "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. Use it when you are waiting on someone's answer and have nothing else to do.",
25230
+ inputSchema: schema({ task: str("Task key to go live on first (optional)"), thread: str("Only a message in this thread"), minutes: int2("Give up after (default 300; 20 with thread)"), project: PROJECT }),
25205
25231
  run: async (args) => {
25206
25232
  const target = await project(args);
25207
25233
  if (args.task) await hold(target.id, String(args.task));
25208
- const queued = inboxDrain(process.cwd());
25209
- if (queued.length) return queued.map(pushBlock).join("\n\n");
25210
- const minutes = typeof args.minutes === "number" ? args.minutes : 300;
25211
- const outcome = await new Promise((resolve5) => {
25212
- const waiter = (delivered) => {
25213
- held?.waiters.delete(waiter);
25214
- clearTimeout(timer);
25215
- resolve5({ push: delivered, released: delivered === null });
25216
- };
25217
- held?.waiters.add(waiter);
25218
- const timer = setTimeout(() => {
25219
- held?.waiters.delete(waiter);
25220
- resolve5({ push: null, released: false });
25221
- }, minutes * 6e4);
25222
- });
25223
- if (outcome.push) return pushBlock(outcome.push);
25224
- if (outcome.released) return "Your person made you not visible to teammates: nothing can reach you now. You may stop.";
25225
- return `No message in ${minutes} minutes.`;
25234
+ const thread = args.thread ? String(args.thread) : null;
25235
+ const minutes = typeof args.minutes === "number" ? args.minutes : thread ? 20 : 300;
25236
+ return waited(await waitFor(thread, minutes), minutes, thread);
25226
25237
  }
25227
25238
  },
25228
25239
  {
@@ -25239,21 +25250,30 @@ ${pushBlock(push)}`;
25239
25250
  {
25240
25251
  name: "reply",
25241
25252
  description: "Reply in a thread. In a thread about a task, it reaches whoever is live on that task now, or waits for them.",
25242
- inputSchema: schema({ thread: str("Thread id"), body: str("The message (Markdown)"), project: PROJECT }, ["thread", "body"]),
25253
+ inputSchema: schema({ thread: str("Thread id"), body: str("The message (Markdown)"), wait: int2("Then wait this many minutes for a reply in the thread"), project: PROJECT }, ["thread", "body"]),
25243
25254
  run: async (args) => {
25244
25255
  const target = await project(args);
25245
- const result = await api.post(`/projects/${target.id}/threads/${String(args.thread)}/messages`, { body: String(args.body) });
25246
- return result.delivered ? `Replied; reached ${result.delivered} live session(s).` : "Replied. Nobody is live on it; they see it when they start.";
25256
+ const thread = String(args.thread);
25257
+ const result = await api.post(`/projects/${target.id}/threads/${thread}/messages`, { body: String(args.body) });
25258
+ const replied = result.delivered ? `Replied; reached ${result.delivered} live session(s).` : "Replied. Nobody is live on it; they see it when they start.";
25259
+ if (typeof args.wait !== "number" || args.wait <= 0) return replied;
25260
+ return `${replied}
25261
+
25262
+ ${waited(await waitFor(thread, args.wait), args.wait, thread)}`;
25247
25263
  }
25248
25264
  },
25249
25265
  {
25250
25266
  name: "post",
25251
25267
  description: "Start a thread. With a task: about that task, to whoever is live on it (or whoever picks it up). Without: general, for people and for context; nobody has to answer.",
25252
- inputSchema: schema({ subject: str("Subject (default: the first line of the body)"), body: str("First message (Markdown)"), task: str("Task key, to make it about that task"), project: PROJECT }, ["body"]),
25268
+ inputSchema: schema({ subject: str("Subject (default: the first line of the body)"), body: str("First message (Markdown)"), task: str("Task key, to make it about that task"), wait: int2("Then wait this many minutes for a reply in the thread"), project: PROJECT }, ["body"]),
25253
25269
  run: async (args) => {
25254
25270
  const target = await project(args);
25255
25271
  const result = await api.post(`/projects/${target.id}/threads`, { subject: args.subject ? String(args.subject) : void 0, body: String(args.body), task: args.task, to: args.to });
25256
- return `Started ${result.thread.id}${result.thread.to ? ` to ${result.thread.to.agent}` : ""}${result.thread.taskKey ? ` on ${result.thread.taskKey}` : result.thread.to ? "" : " (general)"}${result.delivered ? `; reached ${result.delivered} live session(s)` : ""}.`;
25272
+ const started2 = `Started ${result.thread.id}${result.thread.to ? ` to ${result.thread.to.agent}` : ""}${result.thread.taskKey ? ` on ${result.thread.taskKey}` : result.thread.to ? "" : " (general)"}${result.delivered ? `; reached ${result.delivered} live session(s)` : ""}.`;
25273
+ if (typeof args.wait !== "number" || args.wait <= 0) return started2;
25274
+ return `${started2}
25275
+
25276
+ ${waited(await waitFor(result.thread.id, args.wait), args.wait, result.thread.id)}`;
25257
25277
  }
25258
25278
  },
25259
25279
  {
@@ -25545,27 +25565,27 @@ function readJson(path3) {
25545
25565
  return {};
25546
25566
  }
25547
25567
  }
25548
- function installClaude(dir2) {
25568
+ function installClaude(dir) {
25549
25569
  const done = [];
25550
- const primary = dir2 === join12(homedir4(), ".claude");
25570
+ const primary = dir === join11(homedir4(), ".claude");
25551
25571
  const env = { ...process.env };
25552
25572
  if (primary) delete env.CLAUDE_CONFIG_DIR;
25553
- else env.CLAUDE_CONFIG_DIR = dir2;
25554
- const where = primary ? "" : ` for ${dir2}`;
25573
+ else env.CLAUDE_CONFIG_DIR = dir;
25574
+ const where = primary ? "" : ` for ${dir}`;
25555
25575
  try {
25556
25576
  execFileSync5("claude", ["mcp", "add", "--scope", "user", "slop", "--", "slop", "mcp"], { stdio: ["ignore", "ignore", "pipe"], env });
25557
25577
  done.push(`Claude Code: MCP server "slop" registered (user scope${where})`);
25558
25578
  } catch (error62) {
25559
25579
  const said = String(error62.stderr ?? "") + String(error62.stdout ?? "");
25560
25580
  if (/already exists/i.test(said)) done.push(`Claude Code: MCP server "slop" already registered (user scope${where})`);
25561
- else done.push(`Claude Code: run \`${primary ? "" : `CLAUDE_CONFIG_DIR=${dir2} `}claude mcp add --scope user slop -- slop mcp\` (${said.trim() || "the claude command was not found here"})`);
25581
+ 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"})`);
25562
25582
  }
25563
- const path3 = join12(dir2, "settings.json");
25564
- mkdirSync8(dir2, { recursive: true });
25583
+ const path3 = join11(dir, "settings.json");
25584
+ mkdirSync7(dir, { recursive: true });
25565
25585
  const settings = readJson(path3);
25566
25586
  settings.hooks = mergeHooks(settings.hooks ?? {}, "claude");
25567
25587
  settings.env = { ...settings.env ?? {}, MCP_TOOL_TIMEOUT: "21600000" };
25568
- writeFileSync8(path3, `${JSON.stringify(settings, null, 2)}
25588
+ writeFileSync7(path3, `${JSON.stringify(settings, null, 2)}
25569
25589
  `);
25570
25590
  done.push(`Claude Code: SessionStart hook and MCP_TOOL_TIMEOUT written to ${path3}; other slop hooks removed (its session socket wakes it)`);
25571
25591
  return done;
@@ -25584,39 +25604,35 @@ function mergeHooks(hooks, harness) {
25584
25604
  hooks[event] = entries;
25585
25605
  };
25586
25606
  add("SessionStart", "start", 5, "Slopware Tasks");
25587
- if (harness === "codex") {
25588
- add("UserPromptSubmit", "prompt", 5, "Slopware Tasks");
25589
- add("Stop", "stop", 21600, "Listening on Slopware Tasks");
25590
- }
25591
25607
  return hooks;
25592
25608
  }
25593
- function installCodex(dir2) {
25609
+ function installCodex(dir) {
25594
25610
  const done = [];
25595
- mkdirSync8(dir2, { recursive: true });
25596
- const configPath3 = join12(dir2, "config.toml");
25597
- const current = existsSync11(configPath3) ? readFileSync13(configPath3, "utf8") : "";
25611
+ mkdirSync7(dir, { recursive: true });
25612
+ const configPath3 = join11(dir, "config.toml");
25613
+ const current = existsSync10(configPath3) ? readFileSync13(configPath3, "utf8") : "";
25598
25614
  if (/^\[mcp_servers\.slop\]/m.test(current)) {
25599
- if (/tool_timeout_sec = 1800\b/.test(current)) writeFileSync8(configPath3, current.replace(/tool_timeout_sec = 1800\b/, "tool_timeout_sec = 21600"));
25615
+ if (/tool_timeout_sec = 1800\b/.test(current)) writeFileSync7(configPath3, current.replace(/tool_timeout_sec = 1800\b/, "tool_timeout_sec = 21600"));
25600
25616
  done.push(`Codex: MCP server already in ${configPath3}`);
25601
25617
  } else {
25602
- writeFileSync8(configPath3, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
25618
+ writeFileSync7(configPath3, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
25603
25619
  command = "slop"
25604
25620
  args = ["mcp"]
25605
25621
  tool_timeout_sec = 21600
25606
25622
  `);
25607
25623
  done.push(`Codex: MCP server "slop" added to ${configPath3}`);
25608
25624
  }
25609
- const hooksPath = join12(dir2, "hooks.json");
25610
- const file3 = readJson(hooksPath);
25611
- file3.hooks = mergeHooks(file3.hooks ?? {}, "codex");
25612
- writeFileSync8(hooksPath, `${JSON.stringify(file3, null, 2)}
25625
+ const hooksPath = join11(dir, "hooks.json");
25626
+ const file2 = readJson(hooksPath);
25627
+ file2.hooks = mergeHooks(file2.hooks ?? {}, "codex");
25628
+ writeFileSync7(hooksPath, `${JSON.stringify(file2, null, 2)}
25613
25629
  `);
25614
- done.push(`Codex: SessionStart, UserPromptSubmit and Stop hooks written to ${hooksPath}`);
25630
+ done.push(`Codex: SessionStart hook written to ${hooksPath}; other slop hooks removed (no Codex hook holds a turn open)`);
25615
25631
  return done;
25616
25632
  }
25617
25633
  async function trustCodexHooks(codexHome) {
25618
- const server = spawn3("codex", ["app-server"], { stdio: ["pipe", "pipe", "ignore"], env: { ...process.env, CODEX_HOME: codexHome } });
25619
- const where = codexHome === join12(homedir4(), ".codex") ? "" : ` under ${codexHome}`;
25634
+ const server = spawn3("codex", ["app-server", "-c", "mcp_servers={}"], { stdio: ["pipe", "pipe", "ignore"], env: { ...process.env, CODEX_HOME: codexHome } });
25635
+ const where = codexHome === join11(homedir4(), ".codex") ? "" : ` under ${codexHome}`;
25620
25636
  let buffer = "";
25621
25637
  const pending = /* @__PURE__ */ new Map();
25622
25638
  server.stdout.on("data", (chunk) => {
@@ -25651,7 +25667,7 @@ async function trustCodexHooks(codexHome) {
25651
25667
  const notify = (method, params) => server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}
25652
25668
  `);
25653
25669
  try {
25654
- await call("initialize", { clientInfo: { name: "slop", version: "0.4.1" }, capabilities: { experimentalApi: true } });
25670
+ await call("initialize", { clientInfo: { name: "slop", version: "0.4.3" }, capabilities: { experimentalApi: true } });
25655
25671
  notify("initialized", {});
25656
25672
  const listed = await call("hooks/list", { cwds: [process.cwd()] });
25657
25673
  const ours = listed.data.flatMap((entry) => entry.hooks).filter((hook) => hook.command?.startsWith("slop hook "));
@@ -25670,9 +25686,9 @@ async function trustCodexHooks(codexHome) {
25670
25686
  async function healCodexTrust() {
25671
25687
  const home = ownHarnessHome("CODEX_HOME");
25672
25688
  if (!home) return;
25673
- const hooksPath = join12(home, "hooks.json");
25674
- const configPath3 = join12(home, "config.toml");
25675
- if (!existsSync11(hooksPath) || !existsSync11(configPath3)) return;
25689
+ const hooksPath = join11(home, "hooks.json");
25690
+ const configPath3 = join11(home, "config.toml");
25691
+ if (!existsSync10(hooksPath) || !existsSync10(configPath3)) return;
25676
25692
  if (!readFileSync13(hooksPath, "utf8").includes("slop hook stop")) return;
25677
25693
  if (readFileSync13(configPath3, "utf8").includes(`hooks.state."${hooksPath}:stop:`)) return;
25678
25694
  await trustCodexHooks(home);
@@ -25696,10 +25712,10 @@ async function hookInput(harness) {
25696
25712
  return { cwd, conversation };
25697
25713
  }
25698
25714
  async function promptHook(harness) {
25699
- const { cwd } = await hookInput(harness);
25700
- markWake(cwd);
25715
+ await hookInput(harness);
25701
25716
  }
25702
25717
  async function startHook(harness) {
25718
+ if (nonInteractiveHarness()) return;
25703
25719
  const { conversation } = await hookInput(harness);
25704
25720
  if (!conversation) return;
25705
25721
  const text = `On Slopware Tasks you are ${agentNameFor(conversation)}: that is how people and other agents address you (session who lists everyone).`;
@@ -25737,34 +25753,9 @@ ${parts.join("\n")}` })}
25737
25753
  `);
25738
25754
  return;
25739
25755
  }
25740
- const sessionId = rememberedSession(cwd);
25741
- if (!sessionId || harness !== "codex") return;
25742
- const reachable = async () => (await api.get(`/projects/${project.id}/sessions`)).some((session) => session.id === sessionId && session.reachable);
25743
- if (!await reachable()) return;
25744
- const began = Date.now();
25745
- takeWake(cwd, began);
25746
- let lastCheck = began;
25747
- while (Date.now() - began < STANDBY_HOLD_MS) {
25748
- await new Promise((resolve5) => setTimeout(resolve5, 500));
25749
- const arrived = inboxDrain(cwd);
25750
- if (arrived.length) {
25751
- process.stdout.write(`${JSON.stringify({ decision: "block", reason: `A message for you on Slopware Tasks. Act or answer it (say reply), then stop.
25752
-
25753
- ${arrived.map(pushBlock).join("\n\n")}` })}
25754
- `);
25755
- return;
25756
- }
25757
- if (takeWake(cwd, began)) return;
25758
- if (Date.now() - lastCheck > 6e4) {
25759
- lastCheck = Date.now();
25760
- if (!await reachable().catch(() => true)) return;
25761
- }
25762
- }
25763
- process.stdout.write(`${JSON.stringify({ decision: "block", reason: "Still reachable on Slopware Tasks, nothing arrived. Say so in a word and stop; you will wait again." })}
25764
- `);
25765
25756
  }
25766
- var STANDBY_HOLD_MS = 5 * 60 * 60 * 1e3;
25767
25757
  function tools() {
25758
+ if (nonInteractiveHarness()) return [];
25768
25759
  const fine = handlers();
25769
25760
  const run2 = (name, args) => {
25770
25761
  const handler = fine.find((candidate) => candidate.name === name);
@@ -25802,16 +25793,16 @@ function tools() {
25802
25793
  ),
25803
25794
  group(
25804
25795
  "session",
25805
- "Being live. claim: take a task, go live on it, get what is unread (messages then reach you on their own). release; finish is on task. who: live agents by name (you included). check: unread now. wait: block for the next message (optional).",
25796
+ "Being live. claim: take a task, go live on it, get what is unread (messages then reach you on their own). release; finish is on task. who: live agents by name (you included). check: unread now. wait: block for the next message, or the next one in a thread.",
25806
25797
  { claim: "claim_task", release: "release_task", who: "sessions", check: "check_messages", wait: "wait_for_message" },
25807
- { task: str("Task key"), minutes: int2("wait: give up after (default 300)") }
25798
+ { task: str("Task key"), thread: str("wait: only a message in this thread"), minutes: int2("wait: give up after (default 300; 20 with thread)") }
25808
25799
  ),
25809
25800
  group("threads", "Talk between agents and people. list threads; read one (marks it read; newest 40 unless limit/before).", { list: "threads", read: "thread" }, { task: str("list: about this task"), unread: { type: "boolean" }, id: str("read: thread id"), limit: int2("read: newest N"), before: str("read: page back from this time") }),
25810
25801
  group(
25811
25802
  "say",
25812
- "post: start a thread (with task: reaches whoever is live on it; with to: one live agent by name; without either: general, for people). reply: in a thread; reaches its task's live sessions, its participants and its addressee.",
25803
+ "post: start a thread (with task: reaches whoever is live on it; with to: one live agent by name; without either: general, for people). reply: in a thread; reaches its task's live sessions, its participants and its addressee. Waiting on an answer? Add wait (minutes) and the reply comes back in the same call.",
25813
25804
  { post: "post", reply: "reply" },
25814
- { subject: str("post: optional, else the first line"), body: str("Markdown"), task: str("post: about this task"), to: str("post: an agent's name (session who)"), thread: str("reply: thread id") }
25805
+ { subject: str("post: optional, else the first line"), body: str("Markdown"), task: str("post: about this task"), to: str("post: an agent's name (session who)"), thread: str("reply: thread id"), wait: int2("then wait this many minutes for a reply in the thread") }
25815
25806
  )
25816
25807
  ];
25817
25808
  }
@@ -25940,13 +25931,13 @@ ${written.map((path3) => ` ${path3}`).join("\n")}`);
25940
25931
  }
25941
25932
 
25942
25933
  // src/index.ts
25943
- 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.1").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => setJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
25934
+ 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.3").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => setJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
25944
25935
  registerAuthCommands(program2);
25945
25936
  registerProjectCommands(program2);
25946
25937
  var taskCommand = registerTaskCommands(program2);
25947
25938
  registerSessionCommands(program2, taskCommand);
25948
25939
  registerThreadCommands(program2, taskCommand);
25949
- registerMcpCommands(program2, "0.4.1");
25940
+ registerMcpCommands(program2, "0.4.3");
25950
25941
  registerGuideCommand(program2);
25951
25942
  registerSkillCommands(program2);
25952
25943
  program2.parseAsync().catch(fail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slopwaresystems",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "description": "slop: the Slopware Tasks board from the terminal, for people and coding agents.",
6
6
  "type": "module",