slopwaresystems 0.2.2 → 0.2.4

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 +143 -92
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3385,7 +3385,7 @@ var slop_cli_default = '---\nname: slop-cli\ndescription: How to use the slop CL
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: `slop task show <KEY>`. If it has a plan, read the notes; they are what the last session knew.\n2. If there is no plan, write one. Items are concrete, checkable, and ordered by what unlocks what. Eight to fifteen is typical; more means the task should be split.\n\n```bash\nslop task plan <KEY> - <<\'EOF\'\nInventory every Intercom object we rely on (conversations, users, tags)\nDecide the target schema for the support agent\nWrite and test the export script against a sample\nRun the full export; record counts\nImport into the support agent; verify counts match\nSwitch inbound routing; keep Intercom read-only for a week\nRemove the Intercom SDK and env vars\nEOF\n```\n\n\n## Working\n\n- Before an item: `slop task item note <KEY> <n> "Starting. Approach: \u2026"`\n- As facts arrive, replace the note. Notes are state, not a log: "Exported 1,204 conversations; 3 failed (attachments over 10 MB), ids in scripts/export/failed.json".\n- When an item is done: `slop task item done <KEY> <n>`. If it turned out to be two things, add the second: `slop task item add <KEY> "\u2026"`.\n- Blocked? Put the blocker in the note, then move on to an item that is not blocked. Do not mark blocked items done.\n- Decisions go in the description (edit it) so the next reader does not have to reconstruct them.\n\n## Follow-ups and later\n\n- Work discovered but out of scope becomes its own task: `slop task create "\u2026" --status backlog -d "Found while doing <KEY>: \u2026"`.\n- Something to check later on this task is an item: `slop task item add <KEY> "Confirm Intercom exports stayed empty for a week"`.\n\n## Finishing\n\n`slop task items <KEY>` should show every item done or removed. Put what changed and where at the end of the description, link the PR (`slop task link`), then `slop task done <KEY>`.\n\n## Picking up a task you did not start\n\n`slop task show`, `slop task items`, `slop task history`, in that order. The notes tell you the present; the history tells you how it got there. Then continue from the first item that is not done.\n';
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 on one task. It exists while `slop session wait` is running: when that process ends (you exit, or your thread closes), the session ends with it. Sessions are visible to everyone (`slop sessions`, the board\'s live badges) and are advisory: they say who is working, they do not lock anything.\n\n**Threads** are how agents and people talk across sessions. A thread has a subject, a scope (one task, or general) and messages; it is stored, so nothing is lost when a session ends. **The task is the address:** to reach the agent on ST-3, you write in a thread about ST-3. General threads are for people and for context; nobody claims them and you never have to answer one.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, or in **a thread you have written in** (that is how answers find you, even from a general thread or another ticket), reaches you at once. Nothing else does.\n- **Everything else waits.** `slop session start <KEY>` prints the unread threads about that task and the unread general ones (read those for context). `slop threads` lists them any time; `slop thread show <id>` reads one.\n- Your own messages never wake your own waiter.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `claim_task(task)`. Assigns the task to you, moves it to in progress, and returns what is unread about it. Read that first.\n2. **Work.** Keep the plan\'s notes current with `note_item`, tick with `finish_item`.\n3. **You are live from now on.** `claim_task` holds your session for as long as the harness runs the server. A message in a thread about your task, or in any thread you have written in, reaches you on its own: right after your next tool call (any tool; a hook on Claude Code and on Codex hands it to you), and in every slop tool\'s result besides. There is nothing to poll and nothing to restart. When one arrives, act or answer with `reply(thread, body)` and carry on.\n4. **Nothing to do but wait?** `wait_for_message(task, minutes)` blocks until the next message. Optional; most of the time you just keep working.\n5. **Finish:** `finish_task(task)` (it also releases the session), or `release_task` to step off without finishing.\n\nBoth harnesses also refuse to stop while messages are unread about your task (a Stop hook), so nothing is lost at the end of a turn.\n\n## The loop without MCP (scripts, older setups)\n\n`slop session start <KEY>`, then `slop session wait <KEY>` in the background. It prints the first message and exits; **the exit is the message**: read it, reply with `slop thread reply <id> "\u2026"`, run `slop session wait <KEY>` again at once. Exit codes: 0 message, 2 ended on purpose (stop), 3 timeout, 4 dropped (relaunch). `--follow` streams instead of exiting, for harnesses that watch output.\n\n## Talking\n\n```bash\nslop sessions # who is live on what\nslop threads [--task KEY] [--general] [--unread]\nslop thread show <id> # read it (marks it read)\nslop thread reply <id> "\u2026" # in a task thread: reaches whoever is live on it, or waits for them\nslop thread new "Subject" -m "\u2026" --task WEB-12 # a new thread about a task\nslop thread new "Subject" -m "\u2026" # a general thread: for people, and for context\nslop task ask WEB-12 "\u2026" # shorthand: a new thread about WEB-12 from one message\n```\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan\'s notes, decisions in the description.\n\n## Etiquette\n\n- One live session per agent. Do not claim a task that has someone else\'s live session (`sessions`); write in a thread about it first.\n- Answer messages about your task promptly: the other agent is blocked on you.\n- Report progress in the plan\'s notes, not in threads. Threads are for talking to someone.\n- If you stop working on a task, end the session so nobody waits on you.\n';
3388
+ var slop_sessions_default = '---\nname: slop-sessions\ndescription: How an agent claims a task, stays reachable on it (a live session), sees who else is live, and talks to other agents and people through the project\'s message board. Load when starting work on a task, when asked to check on other agents, when a `slop session wait` background task finishes, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is you, live. With the MCP server it exists from your first slop tool call until your harness exits: first as presence in the project (no task), then on the task you claim. Being live means messages find you. Sessions are visible to everyone (`slop sessions`, the board\'s live badges) and are advisory: they say who is working, they do not lock anything.\n\n**Threads** are how agents and people talk across sessions. A thread has a subject, a scope (one task, or general) and messages; it is stored, so nothing is lost when a session ends. **The task is the address:** to reach the agent on ST-3, you write in a thread about ST-3. General threads are for people and for context; nobody claims them and you never have to answer one.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, or in **a thread you have written in** (that is how answers find you, even from a general thread or another ticket), reaches you at once. Nothing else does.\n- **Everything else waits.** `slop session start <KEY>` prints the unread threads about that task and the unread general ones (read those for context). `slop threads` lists them any time; `slop thread show <id>` reads one.\n- Your own messages never wake your own waiter.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `claim_task(task)`. Assigns the task to you, moves it to in progress, and returns what is unread about it. Read that first.\n2. **Work.** Keep the plan\'s notes current with `note_item`, tick with `finish_item`.\n3. **You are live from your first call.** The server holds presence in the project as soon as you use any tool, and `claim_task` upgrades it to the task; `finish_task` or `release_task` drops back to presence. Either way, for as long as the harness runs the server A message in a thread about your task, or in any thread you have written in, reaches you on its own: right after your next tool call (any tool; a hook on Claude Code and on Codex hands it to you), and in every slop tool\'s result besides. There is nothing to poll and nothing to restart. When one arrives, act or answer with `reply(thread, body)` and carry on.\n4. **Nothing to do but wait?** `wait_for_message(task, minutes)` blocks until the next message. Optional; most of the time you just keep working.\n5. **Finish:** `finish_task(task)` (it also releases the session), or `release_task` to step off without finishing.\n\nBoth harnesses also refuse to stop while messages are unread about your task (a Stop hook), so nothing is lost at the end of a turn.\n\n## The loop without MCP (scripts, older setups)\n\n`slop session start <KEY>`, then `slop session wait <KEY>` in the background. It prints the first message and exits; **the exit is the message**: read it, reply with `slop thread reply <id> "\u2026"`, run `slop session wait <KEY>` again at once. Exit codes: 0 message, 2 ended on purpose (stop), 3 timeout, 4 dropped (relaunch). `--follow` streams instead of exiting, for harnesses that watch output.\n\n## Talking\n\n```bash\nslop sessions # who is live on what\nslop threads [--task KEY] [--general] [--unread]\nslop thread show <id> # read it (marks it read)\nslop thread reply <id> "\u2026" # in a task thread: reaches whoever is live on it, or waits for them\nslop thread new "Subject" -m "\u2026" --task WEB-12 # a new thread about a task\nslop thread new "Subject" -m "\u2026" # a general thread: for people, and for context\nslop task ask WEB-12 "\u2026" # shorthand: a new thread about WEB-12 from one message\n```\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan\'s notes, decisions in the description.\n\n## Etiquette\n\n- One live session per agent, and one agent per working directory: run each agent in its own checkout or git worktree, so its inbox is its own.\n- Do not claim a task that has someone else\'s live session (`sessions`); write in a thread about it first.\n- Answer messages about your task promptly: the other agent is blocked on you.\n- Report progress in the plan\'s notes, not in threads. Threads are for talking to someone.\n- If you stop working on a task, end the session so nobody waits on you.\n';
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:** `slop task list --assignee me` (or `slop task list`). If nothing is assigned to you, take the top item from the first "to do" column: `slop task assign <KEY> me`.\n2. **Claim it:** `slop session start <KEY>` (assigns you, moves it to in progress), then run `slop session wait <KEY>` in the background so other agents can reach you (see `slop-sessions`). Read `slop task show <KEY>` fully, including the plan and its notes.\n3. **Do it.** Long work: follow `slop-planning`. Short work: just do it.\n4. **Report as you go**: the item\'s note is the present state of that piece of work; the description holds what you decided and why. Write in sentences a teammate can act on.\n5. **Finish:** link the PR, put what changed and where at the end of the description, `slop task done <KEY>`, `slop session end <KEY>`.\n\n# Where things go\n\n| This | Goes here |\n|---|---|\n| The current truth about a piece of the work | the item\'s note |\n| A decision and its reason | the description (edit it) |\n| A question for the agent on a ticket | a thread about that ticket (`slop task ask KEY "\u2026"`) |\n| A note for people, or for whoever comes next | a general thread (`slop post "\u2026"`) |\n| A discovery, a hand-off | the description, or a new task if it is work |\n| A pull request, a design, a log | a link |\n| Loose working notes nobody needs to act on | the task\'s notes (`slop task notes`) |\n| Work you found but will not do now | a new task in backlog |\n\n# Etiquette\n\n- One task in progress at a time per agent. `slop sessions` shows who is live; do not take a task with someone else\'s live session without asking (`slop task ask`).\n- Never delete a task or someone else\'s items; archive and remove are for the owner.\n- Severity is the impact if nothing is done: `critical` is data loss or money, `high` is users blocked, `medium` is friction, `low` is polish.\n';
@@ -23442,8 +23442,9 @@ var TaskEventRecord = external_exports.object({
23442
23442
  // ../contract/src/work/sessions.ts
23443
23443
  var Session = external_exports.object({
23444
23444
  id: Id,
23445
- taskId: Id,
23446
- taskKey: external_exports.string(),
23445
+ /** Null: the agent is live in the project but on no task yet (presence). Replies in threads it is part of still reach it. */
23446
+ taskId: Id.nullable(),
23447
+ taskKey: external_exports.string().nullable(),
23447
23448
  user: UserRef,
23448
23449
  machine: external_exports.object({ id: Id, name: external_exports.string() }),
23449
23450
  startedAt: Timestamp
@@ -23517,6 +23518,29 @@ function resolveSession() {
23517
23518
  return { baseUrl, token };
23518
23519
  }
23519
23520
 
23521
+ // src/platform/session-file.ts
23522
+ import { createHash } from "crypto";
23523
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
23524
+ import { dirname as dirname2, join as join3 } from "path";
23525
+ var sessionsDir = () => join3(dirname2(configPath()), "sessions");
23526
+ var realCwd = (cwd) => {
23527
+ try {
23528
+ return realpathSync(cwd);
23529
+ } catch {
23530
+ return cwd;
23531
+ }
23532
+ };
23533
+ var sessionFile = (cwd) => join3(sessionsDir(), `${createHash("sha256").update(realCwd(cwd)).digest("hex").slice(0, 16)}.txt`);
23534
+ function rememberSession(cwd, sessionId) {
23535
+ mkdirSync3(sessionsDir(), { recursive: true });
23536
+ if (sessionId) writeFileSync3(sessionFile(cwd), sessionId);
23537
+ else rmSync2(sessionFile(cwd), { force: true });
23538
+ }
23539
+ function rememberedSession(cwd) {
23540
+ const path3 = sessionFile(cwd);
23541
+ return existsSync3(path3) ? readFileSync3(path3, "utf8").trim() || null : null;
23542
+ }
23543
+
23520
23544
  // src/platform/errors.ts
23521
23545
  var CliError = class extends Error {
23522
23546
  constructor(message, hint, code = "error", exitCode = 1) {
@@ -23536,6 +23560,7 @@ function createApi(options) {
23536
23560
  const headers = { accept: "application/json" };
23537
23561
  if (body2 !== void 0) headers["content-type"] = "application/json";
23538
23562
  if (options.token) headers.authorization = `Bearer ${options.token}`;
23563
+ if (client.sessionId) headers["x-slop-session"] = client.sessionId;
23539
23564
  let response;
23540
23565
  try {
23541
23566
  response = await fetch(`${options.baseUrl}/api${path3}`, {
@@ -23561,27 +23586,31 @@ function createApi(options) {
23561
23586
  }
23562
23587
  throw new CliError(`${method} ${path3} failed with HTTP ${response.status}`, void 0, "http");
23563
23588
  }
23564
- return {
23589
+ const client = {
23565
23590
  baseUrl: options.baseUrl,
23591
+ sessionId: null,
23566
23592
  get: (path3) => request("GET", path3),
23567
23593
  post: (path3, body2) => request("POST", path3, body2 ?? {}),
23568
23594
  patch: (path3, body2) => request("PATCH", path3, body2),
23569
23595
  put: (path3, body2) => request("PUT", path3, body2),
23570
23596
  delete: (path3) => request("DELETE", path3)
23571
23597
  };
23598
+ return client;
23572
23599
  }
23573
23600
  function authedApi() {
23574
23601
  const session = resolveSession();
23575
23602
  if (!session.token) throw new CliError(`Not signed in to ${session.baseUrl}`, "slop login", "unauthenticated");
23576
- return createApi(session);
23603
+ const client = createApi(session);
23604
+ client.sessionId = rememberedSession(process.cwd());
23605
+ return client;
23577
23606
  }
23578
23607
 
23579
23608
  // src/platform/machine.ts
23580
23609
  import { execFileSync } from "child_process";
23581
- import { createHash, randomUUID } from "crypto";
23582
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
23610
+ import { createHash as createHash2, randomUUID } from "crypto";
23611
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
23583
23612
  import { hostname as hostname3, platform, userInfo } from "os";
23584
- import { dirname as dirname2, join as join3 } from "path";
23613
+ import { dirname as dirname3, join as join4 } from "path";
23585
23614
  function run(command, args) {
23586
23615
  try {
23587
23616
  return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
@@ -23595,7 +23624,7 @@ function osMachineId() {
23595
23624
  return /"IOPlatformUUID" = "([^"]+)"/.exec(run("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]) ?? "")?.[1] ?? null;
23596
23625
  case "linux":
23597
23626
  for (const path3 of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
23598
- if (existsSync3(path3)) return readFileSync3(path3, "utf8").trim() || null;
23627
+ if (existsSync4(path3)) return readFileSync4(path3, "utf8").trim() || null;
23599
23628
  }
23600
23629
  return null;
23601
23630
  case "win32":
@@ -23607,18 +23636,18 @@ function osMachineId() {
23607
23636
  }
23608
23637
  }
23609
23638
  function persistedMachineId() {
23610
- const path3 = join3(dirname2(configPath()), "machine-id");
23611
- if (existsSync3(path3)) return readFileSync3(path3, "utf8").trim();
23639
+ const path3 = join4(dirname3(configPath()), "machine-id");
23640
+ if (existsSync4(path3)) return readFileSync4(path3, "utf8").trim();
23612
23641
  const id = randomUUID();
23613
- mkdirSync3(dirname2(path3), { recursive: true, mode: 448 });
23614
- writeFileSync3(path3, `${id}
23642
+ mkdirSync4(dirname3(path3), { recursive: true, mode: 448 });
23643
+ writeFileSync4(path3, `${id}
23615
23644
  `, { mode: 384 });
23616
23645
  return id;
23617
23646
  }
23618
23647
  function machineInfo() {
23619
23648
  const raw = `${osMachineId() ?? persistedMachineId()}:${userInfo().username}`;
23620
23649
  return {
23621
- fingerprint: createHash("sha256").update(raw).digest("hex"),
23650
+ fingerprint: createHash2("sha256").update(raw).digest("hex"),
23622
23651
  name: hostname3().replace(/\.local$/, ""),
23623
23652
  platform: platform()
23624
23653
  };
@@ -23759,9 +23788,9 @@ function registerGuideCommand(program3) {
23759
23788
 
23760
23789
  // src/platform/git.ts
23761
23790
  import { execFileSync as execFileSync2 } from "child_process";
23762
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
23763
- import { existsSync as existsSync4, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
23764
- import { basename, join as join4, resolve } from "path";
23791
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
23792
+ import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
23793
+ import { basename, join as join5, resolve } from "path";
23765
23794
  function git(args, cwd = process.cwd()) {
23766
23795
  try {
23767
23796
  return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -23774,7 +23803,7 @@ function findRepo() {
23774
23803
  if (!commonDir) return null;
23775
23804
  const worktree = git(["rev-parse", "--show-toplevel"]);
23776
23805
  const root = basename(commonDir) === ".git" ? resolve(commonDir, "..") : worktree ?? commonDir;
23777
- return { root, worktree: worktree ?? root, linkPath: join4(commonDir, "slop.json") };
23806
+ return { root, worktree: worktree ?? root, linkPath: join5(commonDir, "slop.json") };
23778
23807
  }
23779
23808
  function requireRepo() {
23780
23809
  const repo = findRepo();
@@ -23783,17 +23812,17 @@ function requireRepo() {
23783
23812
  }
23784
23813
  function readLink(repo) {
23785
23814
  try {
23786
- return JSON.parse(readFileSync4(repo.linkPath, "utf8"));
23815
+ return JSON.parse(readFileSync5(repo.linkPath, "utf8"));
23787
23816
  } catch {
23788
23817
  return null;
23789
23818
  }
23790
23819
  }
23791
23820
  function writeLink(repo, link) {
23792
- writeFileSync4(repo.linkPath, `${JSON.stringify(link, null, 2)}
23821
+ writeFileSync5(repo.linkPath, `${JSON.stringify(link, null, 2)}
23793
23822
  `);
23794
23823
  }
23795
23824
  function removeLink(repo) {
23796
- if (existsSync4(repo.linkPath)) rmSync2(repo.linkPath);
23825
+ if (existsSync5(repo.linkPath)) rmSync3(repo.linkPath);
23797
23826
  }
23798
23827
  function normalizeRemote(url2) {
23799
23828
  const scp = /^[^@/]+@([^:/]+):(.+)$/.exec(url2);
@@ -23807,7 +23836,7 @@ function describeRepo(repo, existing) {
23807
23836
  let fingerprint;
23808
23837
  let localRepoId;
23809
23838
  if (roots) {
23810
- fingerprint = createHash2("sha256").update(roots.split("\n").sort().join("\n")).digest("hex");
23839
+ fingerprint = createHash3("sha256").update(roots.split("\n").sort().join("\n")).digest("hex");
23811
23840
  } else {
23812
23841
  localRepoId = existing?.localRepoId ?? randomUUID2();
23813
23842
  fingerprint = `local:${localRepoId}`;
@@ -23951,10 +23980,11 @@ linked to ${link.projectKey} ${link.projectName} (${link.baseUrl})`
23951
23980
 
23952
23981
  // src/commands/mcp.ts
23953
23982
  import { execFileSync as execFileSync3 } from "child_process";
23954
- import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync8, readdirSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
23955
- import { dirname as dirname3 } from "path";
23983
+ import { createHash as createHash4 } from "crypto";
23984
+ import { appendFileSync, existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync9, readdirSync, realpathSync as realpathSync2, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
23985
+ import { dirname as dirname4 } from "path";
23956
23986
  import { homedir as homedir3 } from "os";
23957
- import { join as join5 } from "path";
23987
+ import { join as join6 } from "path";
23958
23988
 
23959
23989
  // src/platform/live.ts
23960
23990
  async function waitForMessage(api, projectId, taskKey, options = {}) {
@@ -23969,6 +23999,8 @@ async function waitForMessage(api, projectId, taskKey, options = {}) {
23969
23999
  socket.addEventListener("message", (event) => {
23970
24000
  const push = JSON.parse(String(event.data));
23971
24001
  if (push.type === "hello") {
24002
+ api.sessionId = push.session.id;
24003
+ rememberSession(process.cwd(), push.session.id);
23972
24004
  options.onHello?.(push.session);
23973
24005
  return;
23974
24006
  }
@@ -23983,6 +24015,7 @@ async function waitForMessage(api, projectId, taskKey, options = {}) {
23983
24015
  socket.addEventListener("close", (event) => done({ kind: "dropped", reason: event.reason || `code ${event.code}` }));
23984
24016
  socket.addEventListener("error", () => done({ kind: "dropped", reason: "socket error" }));
23985
24017
  });
24018
+ if (outcome.kind !== "message") rememberSession(process.cwd(), null);
23986
24019
  if (outcome.kind === "message" || outcome.kind === "timeout") {
23987
24020
  socket.close(1e3, outcome.kind === "message" ? "handled" : "timeout");
23988
24021
  await new Promise((resolve2) => {
@@ -24014,7 +24047,7 @@ var HeldSession = class {
24014
24047
  async connect() {
24015
24048
  if (this.stopped) return;
24016
24049
  try {
24017
- const ticket = await this.api.post(`/projects/${this.projectId}/tasks/${encodeURIComponent(this.taskKey)}/session/ticket`);
24050
+ const ticket = await this.api.post(this.taskKey ? `/projects/${this.projectId}/tasks/${encodeURIComponent(this.taskKey)}/session/ticket` : `/projects/${this.projectId}/session/ticket`);
24018
24051
  const socket = new WebSocket(`${this.api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
24019
24052
  this.socket = socket;
24020
24053
  socket.addEventListener("open", () => {
@@ -24022,6 +24055,11 @@ var HeldSession = class {
24022
24055
  });
24023
24056
  socket.addEventListener("message", (event) => {
24024
24057
  const push = JSON.parse(String(event.data));
24058
+ if (push.type === "hello") {
24059
+ this.api.sessionId = push.session.id;
24060
+ rememberSession(process.cwd(), push.session.id);
24061
+ return;
24062
+ }
24025
24063
  if (push.type === "end") {
24026
24064
  this.stopped = true;
24027
24065
  return;
@@ -24047,6 +24085,8 @@ var HeldSession = class {
24047
24085
  }
24048
24086
  stop() {
24049
24087
  this.stopped = true;
24088
+ this.api.sessionId = null;
24089
+ rememberSession(process.cwd(), null);
24050
24090
  if (this.timer) clearTimeout(this.timer);
24051
24091
  this.socket?.close(1e3, "released");
24052
24092
  }
@@ -24109,11 +24149,11 @@ function serveMcp(tools2, info) {
24109
24149
  }
24110
24150
 
24111
24151
  // src/commands/tasks.ts
24112
- import { readFileSync as readFileSync6 } from "fs";
24152
+ import { readFileSync as readFileSync7 } from "fs";
24113
24153
 
24114
24154
  // src/commands/items.ts
24115
- import { readFileSync as readFileSync5 } from "fs";
24116
- var readText = (value) => value === "-" ? readFileSync5(0, "utf8").trim() : value;
24155
+ import { readFileSync as readFileSync6 } from "fs";
24156
+ var readText = (value) => value === "-" ? readFileSync6(0, "utf8").trim() : value;
24117
24157
  var path2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}/items`;
24118
24158
  function renderItems(items) {
24119
24159
  if (items.length === 0) return "No plan yet. Write one: slop task plan <KEY> -";
@@ -24140,7 +24180,7 @@ function registerItemCommands(task, withProject) {
24140
24180
  withProject(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) => {
24141
24181
  const api = authedApi();
24142
24182
  const project = await resolveProject(api, options.project);
24143
- const source = lines.length === 1 && lines[0] === "-" ? readFileSync5(0, "utf8") : lines.join("\n");
24183
+ const source = lines.length === 1 && lines[0] === "-" ? readFileSync6(0, "utf8") : lines.join("\n");
24144
24184
  const titles = source.split("\n").map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s*(?:\[[ xX]\]\s*)?/, "").trim()).filter(Boolean);
24145
24185
  if (titles.length === 0) throw new CliError("Nothing to add", 'give items as arguments, or "-" and one per line on stdin');
24146
24186
  const items = await api.post(path2(project.id, key), { items: titles.map((title3) => ({ title: title3 })) });
@@ -24197,7 +24237,7 @@ function oneOf(value, allowed, what) {
24197
24237
  if (allowed.includes(normalized)) return normalized;
24198
24238
  throw new CliError(`Unknown ${what} "${value}"`, `use one of: ${allowed.join(", ")}`);
24199
24239
  }
24200
- var readDescription = (value) => value === "-" ? readFileSync6(0, "utf8").trim() : value;
24240
+ var readDescription = (value) => value === "-" ? readFileSync7(0, "utf8").trim() : value;
24201
24241
  var taskUrl = (baseUrl, projectId, task) => `${baseUrl}/projects/${projectId}?task=${task.key}`;
24202
24242
  var loadProject = (api, id) => api.get(`/projects/${id}`);
24203
24243
  function resolveStatus(project, ref) {
@@ -24348,8 +24388,8 @@ ${taskUrl(api.baseUrl, project.id, created)}`);
24348
24388
  }
24349
24389
 
24350
24390
  // src/commands/threads.ts
24351
- import { readFileSync as readFileSync7 } from "fs";
24352
- var readText2 = (value) => value === "-" ? readFileSync7(0, "utf8").trim() : value;
24391
+ import { readFileSync as readFileSync8 } from "fs";
24392
+ var readText2 = (value) => value === "-" ? readFileSync8(0, "utf8").trim() : value;
24353
24393
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
24354
24394
  var who = (message) => `${message.author.name}${message.via === "agent" ? ` (agent${message.machine ? ` \xB7 ${message.machine}` : ""})` : ""}`;
24355
24395
  function threadLine(thread) {
@@ -24430,24 +24470,28 @@ var int2 = (description2) => ({ type: "integer", description: description2 });
24430
24470
  var schema = (properties, required2 = []) => ({ type: "object", properties, required: required2, additionalProperties: false });
24431
24471
  var PROJECT = str("Project key. Defaults to the project this repository is linked to.");
24432
24472
  var taskPath = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24433
- var inboxDir = () => join5(dirname3(configPath()), "inbox");
24434
- var inboxFile = (projectId) => join5(inboxDir(), `${projectId}.jsonl`);
24435
- function inboxWrite(projectId, push) {
24436
- mkdirSync4(inboxDir(), { recursive: true });
24437
- appendFileSync(inboxFile(projectId), `${JSON.stringify(push)}
24473
+ var inboxDir = () => join6(dirname4(configPath()), "inbox");
24474
+ var realCwd2 = (cwd) => {
24475
+ try {
24476
+ return realpathSync2(cwd);
24477
+ } catch {
24478
+ return cwd;
24479
+ }
24480
+ };
24481
+ var inboxKey = (cwd) => createHash4("sha256").update(realCwd2(cwd)).digest("hex").slice(0, 16);
24482
+ var inboxFile = (cwd) => join6(inboxDir(), `${inboxKey(cwd)}.jsonl`);
24483
+ function inboxWrite(cwd, push) {
24484
+ mkdirSync5(inboxDir(), { recursive: true });
24485
+ appendFileSync(inboxFile(cwd), `${JSON.stringify(push)}
24438
24486
  `);
24439
24487
  }
24440
- function inboxDrain(projectId) {
24441
- const path3 = inboxFile(projectId);
24442
- if (!existsSync5(path3)) return [];
24443
- const lines = readFileSync8(path3, "utf8").split("\n").filter(Boolean);
24444
- rmSync3(path3, { force: true });
24488
+ function inboxDrain(cwd) {
24489
+ const path3 = inboxFile(cwd);
24490
+ if (!existsSync6(path3)) return [];
24491
+ const lines = readFileSync9(path3, "utf8").split("\n").filter(Boolean);
24492
+ rmSync4(path3, { force: true });
24445
24493
  return lines.map((line) => JSON.parse(line));
24446
24494
  }
24447
- function inboxDrainAll() {
24448
- if (!existsSync5(inboxDir())) return [];
24449
- return readdirSync(inboxDir()).flatMap((name) => inboxDrain(name.replace(/\.jsonl$/, "")));
24450
- }
24451
24495
  async function digest(api, projectId, taskKey) {
24452
24496
  const [about, mine, general] = await Promise.all([
24453
24497
  taskKey ? api.get(`/projects/${projectId}/threads?task=${encodeURIComponent(taskKey)}&unread=1&read=1`) : Promise.resolve([]),
@@ -24463,17 +24507,21 @@ async function digest(api, projectId, taskKey) {
24463
24507
  }
24464
24508
  function tools() {
24465
24509
  const api = authedApi();
24466
- const project = (args) => resolveProject(api, args.project);
24510
+ const resolveOnly = (args) => resolveProject(api, args.project);
24467
24511
  let held = null;
24468
24512
  const hold = async (projectId, taskKey) => {
24469
24513
  if (held && held.projectId === projectId && held.taskKey === taskKey) return;
24470
24514
  held?.stop();
24471
- held = new HeldSession(api, projectId, taskKey, (push) => inboxWrite(projectId, push));
24515
+ held = new HeldSession(api, projectId, taskKey, (push) => inboxWrite(process.cwd(), push));
24472
24516
  await held.start();
24473
24517
  };
24474
- const release = () => {
24475
- held?.stop();
24476
- held = null;
24518
+ const project = async (args) => {
24519
+ const target = await resolveOnly(args);
24520
+ if (!held) await hold(target.id, null);
24521
+ return target;
24522
+ };
24523
+ const release = async () => {
24524
+ if (held?.taskKey) await hold(held.projectId, null);
24477
24525
  };
24478
24526
  return [
24479
24527
  {
@@ -24483,7 +24531,7 @@ function tools() {
24483
24531
  run: async (args) => {
24484
24532
  const target = await project(args);
24485
24533
  await hold(target.id, String(args.task));
24486
- const queued = inboxDrain(target.id);
24534
+ const queued = inboxDrain(process.cwd());
24487
24535
  if (queued.length) return queued.map(pushBlock).join("\n\n");
24488
24536
  const minutes = typeof args.minutes === "number" ? args.minutes : 10;
24489
24537
  const push = await new Promise((resolve2) => {
@@ -24506,7 +24554,7 @@ function tools() {
24506
24554
  inputSchema: schema({ task: str("Task key"), project: PROJECT }),
24507
24555
  run: async (args) => {
24508
24556
  const target = await project(args);
24509
- const queued = inboxDrain(target.id).map(pushBlock);
24557
+ const queued = inboxDrain(process.cwd()).map(pushBlock);
24510
24558
  const rest = await digest(api, target.id, args.task ?? null);
24511
24559
  return [...queued, rest].join("\n\n");
24512
24560
  }
@@ -24552,11 +24600,11 @@ function tools() {
24552
24600
  },
24553
24601
  {
24554
24602
  name: "sessions",
24555
- description: "Who is live on which task right now.",
24603
+ description: "Who is live right now: on which task, or present in the project on no task yet.",
24556
24604
  inputSchema: schema({ project: PROJECT }),
24557
24605
  run: async (args) => {
24558
24606
  const list = await api.get(`/projects/${(await project(args)).id}/sessions`);
24559
- return list.length ? list.map((session) => `${session.taskKey} ${session.user.name} ${session.machine.name} since ${session.startedAt}`).join("\n") : "Nobody is live.";
24607
+ return list.length ? list.map((session) => `${session.taskKey ?? "(no task)"} ${session.user.name} ${session.machine.name} since ${session.startedAt}`).join("\n") : "Nobody is live.";
24560
24608
  }
24561
24609
  },
24562
24610
  {
@@ -24575,11 +24623,11 @@ ${await digest(api, target.id, task.key)}`;
24575
24623
  },
24576
24624
  {
24577
24625
  name: "release_task",
24578
- description: "Stop being live on the task you hold (without finishing it).",
24626
+ description: "Step off the task you hold without finishing it. You stay live in the project, reachable in threads you are part of.",
24579
24627
  inputSchema: schema({ project: PROJECT }),
24580
24628
  run: async () => {
24581
- release();
24582
- return "Released.";
24629
+ await release();
24630
+ return "Released. Still live in the project.";
24583
24631
  }
24584
24632
  },
24585
24633
  {
@@ -24631,15 +24679,15 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
24631
24679
  run: async (args) => {
24632
24680
  const target = await loadProject(api, (await project(args)).id);
24633
24681
  const task = await api.patch(taskPath(target.id, String(args.task)), { status: statusInCategory(target, "done") });
24634
- release();
24635
- return `${task.key} is done.`;
24682
+ await release();
24683
+ return `${task.key} is done. You are still live in the project, reachable in threads you are part of.`;
24636
24684
  }
24637
24685
  }
24638
24686
  ];
24639
24687
  }
24640
24688
  function readJson(path3) {
24641
24689
  try {
24642
- return JSON.parse(readFileSync8(path3, "utf8"));
24690
+ return JSON.parse(readFileSync9(path3, "utf8"));
24643
24691
  } catch {
24644
24692
  return {};
24645
24693
  }
@@ -24652,13 +24700,13 @@ function installClaude() {
24652
24700
  } catch {
24653
24701
  done.push("Claude Code: run `claude mcp add --scope user slop -- slop mcp` (the claude command was not found here)");
24654
24702
  }
24655
- const dir = join5(homedir3(), ".claude");
24656
- const path3 = join5(dir, "settings.json");
24657
- mkdirSync4(dir, { recursive: true });
24703
+ const dir = join6(homedir3(), ".claude");
24704
+ const path3 = join6(dir, "settings.json");
24705
+ mkdirSync5(dir, { recursive: true });
24658
24706
  const settings = readJson(path3);
24659
24707
  settings.hooks = mergeHooks(settings.hooks ?? {});
24660
24708
  settings.env = { ...settings.env ?? {}, MCP_TOOL_TIMEOUT: "1800000" };
24661
- writeFileSync5(path3, `${JSON.stringify(settings, null, 2)}
24709
+ writeFileSync6(path3, `${JSON.stringify(settings, null, 2)}
24662
24710
  `);
24663
24711
  done.push(`Claude Code: PostToolUse and Stop hooks and MCP_TOOL_TIMEOUT written to ${path3}`);
24664
24712
  return done;
@@ -24676,29 +24724,42 @@ function mergeHooks(hooks) {
24676
24724
  }
24677
24725
  function installCodex() {
24678
24726
  const done = [];
24679
- const dir = join5(homedir3(), ".codex");
24680
- mkdirSync4(dir, { recursive: true });
24681
- const configPath2 = join5(dir, "config.toml");
24682
- const current = existsSync5(configPath2) ? readFileSync8(configPath2, "utf8") : "";
24727
+ const dir = join6(homedir3(), ".codex");
24728
+ mkdirSync5(dir, { recursive: true });
24729
+ const configPath2 = join6(dir, "config.toml");
24730
+ const current = existsSync6(configPath2) ? readFileSync9(configPath2, "utf8") : "";
24683
24731
  if (/^\[mcp_servers\.slop\]/m.test(current)) done.push(`Codex: MCP server already in ${configPath2}`);
24684
24732
  else {
24685
- writeFileSync5(configPath2, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
24733
+ writeFileSync6(configPath2, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
24686
24734
  command = "slop"
24687
24735
  args = ["mcp"]
24688
24736
  tool_timeout_sec = 1800
24689
24737
  `);
24690
24738
  done.push(`Codex: MCP server "slop" added to ${configPath2}`);
24691
24739
  }
24692
- const hooksPath = join5(dir, "hooks.json");
24740
+ const hooksPath = join6(dir, "hooks.json");
24693
24741
  const file2 = readJson(hooksPath);
24694
24742
  file2.hooks = mergeHooks(file2.hooks ?? {});
24695
- writeFileSync5(hooksPath, `${JSON.stringify(file2, null, 2)}
24743
+ writeFileSync6(hooksPath, `${JSON.stringify(file2, null, 2)}
24696
24744
  `);
24697
24745
  done.push(`Codex: PostToolUse and Stop hooks written to ${hooksPath}. Codex asks you to trust new hooks once (/hooks in a session).`);
24698
24746
  return done;
24699
24747
  }
24700
- function tickHook() {
24701
- const arrived = inboxDrainAll();
24748
+ async function hookCwd() {
24749
+ const input2 = await new Promise((resolve2) => {
24750
+ let data = "";
24751
+ process.stdin.on("data", (chunk) => data += chunk);
24752
+ process.stdin.on("end", () => resolve2(data));
24753
+ setTimeout(() => resolve2(data), 2e3);
24754
+ });
24755
+ try {
24756
+ return JSON.parse(input2).cwd ?? process.cwd();
24757
+ } catch {
24758
+ return process.cwd();
24759
+ }
24760
+ }
24761
+ async function tickHook() {
24762
+ const arrived = inboxDrain(await hookCwd());
24702
24763
  if (arrived.length === 0) return;
24703
24764
  const text = `Messages arrived on Slopware Tasks while you worked:
24704
24765
 
@@ -24709,19 +24770,9 @@ Act or answer with the slop MCP \`reply\` tool, then carry on.`;
24709
24770
  `);
24710
24771
  }
24711
24772
  async function stopHook() {
24712
- const input2 = await new Promise((resolve2) => {
24713
- let data = "";
24714
- process.stdin.on("data", (chunk) => data += chunk);
24715
- process.stdin.on("end", () => resolve2(data));
24716
- setTimeout(() => resolve2(data), 2e3);
24717
- });
24718
- let cwd = process.cwd();
24719
- try {
24720
- cwd = JSON.parse(input2).cwd ?? cwd;
24721
- } catch {
24722
- }
24773
+ const cwd = await hookCwd();
24723
24774
  process.chdir(cwd);
24724
- const parts = inboxDrainAll().map(pushBlock);
24775
+ const parts = inboxDrain(cwd).map(pushBlock);
24725
24776
  const repo = findRepo();
24726
24777
  const link = repo ? readLink(repo) : null;
24727
24778
  if (!link) {
@@ -24753,7 +24804,7 @@ function withInbox(list) {
24753
24804
  ...tool,
24754
24805
  run: async (args) => {
24755
24806
  const result = await tool.run(args);
24756
- const arrived = inboxDrainAll();
24807
+ const arrived = inboxDrain(process.cwd());
24757
24808
  return arrived.length ? `${result}
24758
24809
 
24759
24810
  --- Messages arrived while you worked ---
@@ -24775,9 +24826,9 @@ function registerMcpCommands(program3, version2) {
24775
24826
  }
24776
24827
 
24777
24828
  // src/commands/sessions.ts
24778
- import { readFileSync as readFileSync9 } from "fs";
24829
+ import { readFileSync as readFileSync10 } from "fs";
24779
24830
  var taskPath2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24780
- var sessionLine = (session) => [session.taskKey, session.user.name, session.machine.name, session.startedAt];
24831
+ var sessionLine = (session) => [session.taskKey ?? "(no task)", session.user.name, session.machine.name, session.startedAt];
24781
24832
  function registerSessionCommands(program3, task) {
24782
24833
  const withProject = (command) => command.option("-p, --project <key>", "project key (default: the project this repository is linked to)");
24783
24834
  const session = program3.command("session").description("Claim a task and be reachable on it while you work.");
@@ -24859,13 +24910,13 @@ ${written.map((path3) => ` ${path3}`).join("\n")}`);
24859
24910
  }
24860
24911
 
24861
24912
  // src/index.ts
24862
- var program2 = new Command().name("slop").description("Slopware Tasks from the terminal. Everything a person can do on the board, an agent can do here.").version("0.2.2").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)");
24913
+ var program2 = new Command().name("slop").description("Slopware Tasks from the terminal. Everything a person can do on the board, an agent can do here.").version("0.2.4").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => setJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
24863
24914
  registerAuthCommands(program2);
24864
24915
  registerProjectCommands(program2);
24865
24916
  var taskCommand = registerTaskCommands(program2);
24866
24917
  registerSessionCommands(program2, taskCommand);
24867
24918
  registerThreadCommands(program2, taskCommand);
24868
- registerMcpCommands(program2, "0.2.2");
24919
+ registerMcpCommands(program2, "0.2.4");
24869
24920
  registerGuideCommand(program2);
24870
24921
  registerSkillCommands(program2);
24871
24922
  program2.parseAsync().catch(fail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slopwaresystems",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "private": false,
5
5
  "description": "slop: the Slopware Tasks board from the terminal, for people and coding agents.",
6
6
  "type": "module",