slopwaresystems 0.3.0 → 0.4.1

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 +908 -190
  2. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -3379,13 +3379,13 @@ import { homedir } from "os";
3379
3379
  import { join } from "path";
3380
3380
 
3381
3381
  // skills/slop-cli.md
3382
- var slop_cli_default = "---\nname: slop-cli\ndescription: How an agent works the Slopware Tasks board through the slop MCP tools (board, task, plan, session, threads, say), and what the slop command is for (people and machines: login, link, install). Load when asked to create, update, plan, discuss or finish tasks.\n---\n\n# The board, as six tools\n\nYour harness has the `slop` MCP server. Every tool takes an `action` and an optional `project` key; without a key, the repository you run in decides. Descriptions and notes are Markdown. Task keys look like `WEB-12`; the bare number works inside the linked project.\n\n| Tool | Actions |\n|---|---|\n| `board` | `list` (`mine`, `status`, `all`, `archived`), `columns`, `members`, `projects` |\n| `task` | `show`, `create`, `update` (only given fields change), `move`, `link`, `archive`, `restore`, `history`, `finish` |\n| `plan` | `add` (`items`), `note`, `done`, `reopen`, `rename`, `remove` (by `item` number) |\n| `session` | `claim`, `release`, `who`, `check`, `wait` (see `slop-sessions`) |\n| `threads` | `list`, `read` |\n| `say` | `post` (new thread; with `task` it reaches whoever is live on it, without it is general), `reply` |\n\nDo not shell out to `slop` for board work; the tools are the way.\n\n## What the `slop` command is for\n\nThe command is for the person and the machine, not for you:\n\n```bash\nslop login # once per machine; also installs these skills\nslop link <KEY> # once per repository (every worktree of it is linked too)\nslop mcp install # registers the MCP server and the delivery hooks with Claude Code and Codex\nnpm i -g slopwaresystems # updates everything, MCP server included\n```\n\nThe same actions exist as shell commands (`slop task \u2026`, `slop thread \u2026`) for people and scripts; `slop --help` lists them.\n\n## Rules of the road\n\n- Do not delete tasks you did not create. Archive finished work instead.\n- Keep titles short and imperative; put the reasoning in the description or the plan's notes.\n- When you open a pull request for a task, link it: `task` with `action: link`.\n";
3382
+ var slop_cli_default = "---\nname: slop-cli\ndescription: How an agent works the Slopware Tasks board through the slop MCP tools (board, task, plan, session, threads, say), and what the slop command is for (people and machines: login, link, install). Load when asked to create, update, plan, discuss or finish tasks.\n---\n\n# The board, as six tools\n\nYour harness has the `slop` MCP server. Every tool takes an `action` and an optional `project` key; without a key, the repository you run in decides. Descriptions and notes are Markdown. Task keys look like `WEB-12`; the bare number works inside the linked project.\n\n| Tool | Actions |\n|---|---|\n| `board` | `list` (`mine`, `status`, `all`, `archived`; `limit`/`offset` to page), `columns`, `members`, `projects` |\n| `task` | `show`, `create`, `update` (only given fields change), `move`, `link`, `archive`, `restore`, `history` (`limit`/`before` to page back), `finish` |\n| `plan` | `add` (`items`), `note`, `done`, `reopen`, `rename`, `remove` (by `item` number) |\n| `session` | `claim`, `release`, `who` (every live agent by name, you marked), `check`, `wait` (optional; messages reach you on their own; see `slop-sessions`) |\n| `threads` | `list`, `read` (newest 40; `limit`/`before` to page back) |\n| `say` | `post` (new thread; with `task` it reaches whoever is live on it; with `to: <name>` it reaches that one agent; with neither it is general), `reply` |\n\nYou have a name, given at session start (the `SessionStart` hook says it) and shown by `session who`. People and other agents address you by it. Long answers are cut at 16k characters and say so; ask for less with `limit`, `offset` or `before`.\n\nDo not shell out to `slop` for board work; the tools are the way.\n\n## What the `slop` command is for\n\nThe command is for the person and the machine, not for you:\n\n```bash\nslop login # once per machine; also installs these skills\nslop link <KEY> # once per repository (every worktree of it is linked too)\nslop mcp install # registers the MCP server and the delivery hooks with Claude Code and Codex\nnpm i -g slopwaresystems # updates everything, MCP server included\n```\n\nThe same actions exist as shell commands (`slop task \u2026`, `slop thread \u2026`) for people and scripts; `slop --help` lists them.\n\n## Rules of the road\n\n- Do not delete tasks you did not create. Archive finished work instead.\n- Keep titles short and imperative; put the reasoning in the description or the plan's notes.\n- When you open a pull request for a task, link it: `task` with `action: link`.\n";
3383
3383
 
3384
3384
  // skills/slop-planning.md
3385
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 who is live on what\nthreads list (task?, unread?) list; threads read (id) reads one (marks it read)\nsay reply (thread, body) in a task thread: reaches whoever is live on it, or waits for them\nsay post (subject, body, task: \"WEB-12\") a new thread about a task\nsay post (subject, body) a general thread: for people, and for context\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 (`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\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";
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 file2 = join(home, name, "SKILL.md");
3402
- if (!force && existsSync(file2) && readFileSync(file2, "utf8") === markdown) continue;
3401
+ const file3 = join(home, name, "SKILL.md");
3402
+ if (!force && existsSync(file3) && readFileSync(file3, "utf8") === markdown) continue;
3403
3403
  mkdirSync(join(home, name), { recursive: true });
3404
- writeFileSync(file2, markdown);
3405
- written.push(file2);
3404
+ writeFileSync(file3, markdown);
3405
+ written.push(file3);
3406
3406
  }
3407
3407
  }
3408
3408
  return written;
@@ -8438,7 +8438,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
8438
8438
  var NONE = 0;
8439
8439
  var ASSUMED = 1;
8440
8440
  var PROVEN = 2;
8441
- function isRecursive(inst, stack, resolve2) {
8441
+ function isRecursive(inst, stack, resolve5) {
8442
8442
  const cached2 = recursive.get(inst);
8443
8443
  if (cached2 !== void 0)
8444
8444
  return cached2 ? PROVEN : NONE;
@@ -8448,7 +8448,7 @@ function isRecursive(inst, stack, resolve2) {
8448
8448
  let result = NONE;
8449
8449
  const check2 = (child) => {
8450
8450
  if (result !== PROVEN && child?._zod) {
8451
- const answer = isRecursive(child, stack, resolve2);
8451
+ const answer = isRecursive(child, stack, resolve5);
8452
8452
  if (answer > result)
8453
8453
  result = answer;
8454
8454
  }
@@ -8459,7 +8459,7 @@ function isRecursive(inst, stack, resolve2) {
8459
8459
  const desc = Object.getOwnPropertyDescriptor(sh, key);
8460
8460
  if (spread && !desc.enumerable)
8461
8461
  continue;
8462
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve2) : NONE;
8462
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve5) : NONE;
8463
8463
  if (child > answer)
8464
8464
  answer = child;
8465
8465
  }
@@ -8523,7 +8523,7 @@ function isRecursive(inst, stack, resolve2) {
8523
8523
  break;
8524
8524
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
8525
8525
  case "lazy": {
8526
- const inner = def._cachedInner ?? (resolve2 ? inst._zod.innerType : void 0);
8526
+ const inner = def._cachedInner ?? (resolve5 ? inst._zod.innerType : void 0);
8527
8527
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
8528
8528
  break;
8529
8529
  }
@@ -19054,12 +19054,12 @@ function processSchema(schema2, ctx, _params = { path: [], schemaPath: [] }) {
19054
19054
  }
19055
19055
  processor(schema2, ctx, _json, params);
19056
19056
  }
19057
- const parent = schema2._zod.parent;
19058
- if (parent) {
19057
+ const parent2 = schema2._zod.parent;
19058
+ if (parent2) {
19059
19059
  if (!result.ref)
19060
- result.ref = parent;
19061
- processSchema(parent, ctx, params);
19062
- ctx.seen.get(parent).isParent = true;
19060
+ result.ref = parent2;
19061
+ processSchema(parent2, ctx, params);
19062
+ ctx.seen.get(parent2).isParent = true;
19063
19063
  }
19064
19064
  }
19065
19065
  const meta3 = ctx.metadataRegistry.get(schema2);
@@ -19322,10 +19322,10 @@ function finalize(ctx, schema2) {
19322
19322
  }
19323
19323
  }
19324
19324
  }
19325
- const parent = zodSchema._zod.parent;
19326
- if (parent && parent !== ref) {
19327
- flattenRef(parent);
19328
- const parentSeen = ctx.seen.get(parent);
19325
+ const parent2 = zodSchema._zod.parent;
19326
+ if (parent2 && parent2 !== ref) {
19327
+ flattenRef(parent2);
19328
+ const parentSeen = ctx.seen.get(parent2);
19329
19329
  if (parentSeen?.schema.$ref) {
19330
19330
  schema3.$ref = parentSeen.schema.$ref;
19331
19331
  if (parentSeen.def) {
@@ -23130,9 +23130,13 @@ var User = external_exports.object({
23130
23130
  avatarUrl: external_exports.string().nullable(),
23131
23131
  /** False for accounts that only ever signed in with a code. */
23132
23132
  hasPassword: external_exports.boolean(),
23133
+ /** New sessions of this person's agents start reachable by teammates (visible, messaged, messaging). */
23134
+ shareAgents: external_exports.boolean(),
23135
+ /** The first sign-in's welcome (the agent prompt) has been shown. */
23136
+ welcomed: external_exports.boolean(),
23133
23137
  createdAt: Timestamp
23134
23138
  });
23135
- var UpdateMeInput = external_exports.object({ name: external_exports.string().trim().min(1).max(80) });
23139
+ var UpdateMeInput = external_exports.object({ name: external_exports.string().trim().min(1).max(80).optional(), shareAgents: external_exports.boolean().optional(), welcomed: external_exports.literal(true).optional() });
23136
23140
  var AVATAR_CONTENT_TYPES = ["image/webp", "image/jpeg", "image/png"];
23137
23141
  var AVATAR_MAX_BYTES = 1e5;
23138
23142
  var SetAvatarInput = external_exports.object({
@@ -23209,12 +23213,14 @@ var Invitation = external_exports.object({
23209
23213
  });
23210
23214
  var InviteLink = external_exports.object({
23211
23215
  id: Id,
23216
+ /** Who it is for, as typed when it was made; shown in the members table so a link can be told from another. */
23217
+ label: external_exports.string().nullable(),
23212
23218
  role: OrgRole,
23213
23219
  createdAt: Timestamp,
23214
23220
  expiresAt: Timestamp.nullable(),
23215
23221
  uses: external_exports.number()
23216
23222
  });
23217
- var CreateInviteLinkInput = external_exports.object({ role: OrgRole.default("member"), expiresInHours: external_exports.number().int().min(1).max(24 * 30).nullable().default(24) });
23223
+ var CreateInviteLinkInput = external_exports.object({ role: OrgRole.default("member"), expiresInHours: external_exports.number().int().min(1).max(24 * 30).nullable().default(24), label: external_exports.string().trim().max(120).optional() });
23218
23224
  var CreatedInviteLink = InviteLink.extend({ url: external_exports.string() });
23219
23225
  var JoinPreview = external_exports.object({ org: external_exports.object({ id: Id, name: external_exports.string() }), role: OrgRole, alreadyMember: external_exports.boolean() });
23220
23226
  var MembersResult = external_exports.object({
@@ -23314,6 +23320,8 @@ var ThreadMessage = external_exports.object({
23314
23320
  /** A person on the web, or an agent through the CLI (with its machine). */
23315
23321
  via: MessageVia,
23316
23322
  machine: external_exports.string().nullable(),
23323
+ /** The agent's name, when a live session wrote it; null for a person, or an older CLI. */
23324
+ agent: external_exports.string().nullable(),
23317
23325
  body: external_exports.string(),
23318
23326
  createdAt: Timestamp
23319
23327
  });
@@ -23324,20 +23332,25 @@ var Thread = external_exports.object({
23324
23332
  taskId: Id.nullable(),
23325
23333
  taskKey: external_exports.string().nullable(),
23326
23334
  subject: external_exports.string(),
23335
+ /** Addressed to one live agent (by its session); replies reach it wherever it is. Null: to the task's agents, or general. */
23336
+ to: external_exports.object({ sessionId: external_exports.string(), agent: external_exports.string() }).nullable(),
23327
23337
  createdBy: UserRef,
23328
23338
  createdAt: Timestamp,
23329
23339
  lastMessageAt: Timestamp,
23330
23340
  messageCount: external_exports.number(),
23331
23341
  /** Messages the viewer has not read. Their own count as read. */
23332
23342
  unread: external_exports.number(),
23333
- last: external_exports.object({ author: UserRef, via: MessageVia, body: external_exports.string() }).nullable()
23343
+ last: external_exports.object({ author: UserRef, via: MessageVia, agent: external_exports.string().nullable(), body: external_exports.string() }).nullable()
23334
23344
  });
23335
- var ThreadDetail = Thread.extend({ messages: external_exports.array(ThreadMessage) });
23345
+ var ThreadDetail = Thread.extend({ messages: external_exports.array(ThreadMessage), earlier: external_exports.number().default(0) });
23336
23346
  var body = external_exports.string().trim().min(1, "Say something").max(8e3);
23337
23347
  var CreateThreadInput = external_exports.object({
23338
- subject: external_exports.string().trim().min(1, "Give it a subject").max(200),
23348
+ /** Absent, the first line of the body is the subject. */
23349
+ subject: external_exports.string().trim().min(1, "Give it a subject").max(200).optional(),
23339
23350
  /** A task key or number; absent means general. */
23340
23351
  task: external_exports.string().trim().min(1).optional(),
23352
+ /** A live agent, by session id or name (as `session who` lists them). */
23353
+ to: external_exports.string().trim().min(1).max(200).optional(),
23341
23354
  body
23342
23355
  });
23343
23356
  var ReplyInput = external_exports.object({ body });
@@ -23440,14 +23453,35 @@ var TaskEventRecord = external_exports.object({
23440
23453
  });
23441
23454
 
23442
23455
  // ../contract/src/work/sessions.ts
23456
+ var AgentHarness = external_exports.enum(["claude", "codex", "other"]);
23443
23457
  var Session = external_exports.object({
23458
+ /** Derived from the person, the machine and the agent's conversation: the same across reconnects and resumes. */
23444
23459
  id: Id,
23445
23460
  /** 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
23461
  taskId: Id.nullable(),
23447
23462
  taskKey: external_exports.string().nullable(),
23448
23463
  user: UserRef,
23449
23464
  machine: external_exports.object({ id: Id, name: external_exports.string() }),
23450
- startedAt: Timestamp
23465
+ /** The agent itself: a generated name (see agent-names) and which harness runs it. */
23466
+ agent: external_exports.object({ name: external_exports.string(), harness: AgentHarness.nullable() }),
23467
+ /** Where it runs, to tell two agents under one person apart. */
23468
+ where: external_exports.object({ directory: external_exports.string().nullable(), branch: external_exports.string().nullable() }),
23469
+ startedAt: Timestamp,
23470
+ /** "reconnecting": its socket dropped and the room is holding the session open for it to come back. */
23471
+ state: external_exports.enum(["live", "reconnecting"]),
23472
+ /**
23473
+ * Reachable by teammates: listed to them, messaged by them and by their agents, and able to message. Off, the agent
23474
+ * still works the board but talks to nobody and is listed only to its own person. Starts from the person's default.
23475
+ */
23476
+ reachable: external_exports.boolean().default(true)
23477
+ });
23478
+ var ReachableInput = external_exports.object({ on: external_exports.boolean() });
23479
+ var SessionClaimInput = external_exports.object({
23480
+ /** Stable for one conversation of the agent (the harness's own session id), so a resumed chat is the same session and keeps its name. */
23481
+ conversation: external_exports.string().trim().min(1).max(200).optional(),
23482
+ harness: AgentHarness.nullable().default(null),
23483
+ directory: external_exports.string().trim().max(200).nullable().default(null),
23484
+ branch: external_exports.string().trim().max(200).nullable().default(null)
23451
23485
  });
23452
23486
  var ConnectTicket = external_exports.object({ ticket: external_exports.string(), path: external_exports.string() });
23453
23487
  var AskInput = external_exports.object({ body: external_exports.string().trim().min(1, "Say something").max(4e3) });
@@ -23455,13 +23489,127 @@ var SessionPush = external_exports.discriminatedUnion("type", [
23455
23489
  external_exports.object({ type: external_exports.literal("hello"), session: Session }),
23456
23490
  external_exports.object({ type: external_exports.literal("event"), record: TaskEventRecord }),
23457
23491
  /** A message in a thread about the task this session is on. */
23458
- external_exports.object({ type: external_exports.literal("thread"), thread: external_exports.object({ id: Id, subject: external_exports.string(), taskKey: external_exports.string().nullable() }), message: ThreadMessage }),
23492
+ external_exports.object({ type: external_exports.literal("thread"), thread: external_exports.object({ id: Id, subject: external_exports.string(), taskKey: external_exports.string().nullable(), to: external_exports.string().nullable().optional() }), message: ThreadMessage }),
23459
23493
  /** The room is closing this session (someone ran `slop session end`). */
23460
23494
  external_exports.object({ type: external_exports.literal("end") }),
23495
+ /** Reachability was switched from the web. Off releases a waiting agent: nothing can reach it any more. */
23496
+ external_exports.object({ type: external_exports.literal("reachable"), on: external_exports.boolean() }),
23461
23497
  /** To board watchers: something in the project changed; refetch. */
23462
23498
  external_exports.object({ type: external_exports.literal("changed"), scope: external_exports.string() })
23463
23499
  ]);
23464
23500
 
23501
+ // ../contract/src/work/agent-names.ts
23502
+ var AGENT_NAMES = [
23503
+ "Albert",
23504
+ "Alma",
23505
+ "Amos",
23506
+ "Astrid",
23507
+ "August",
23508
+ "Beatrix",
23509
+ "Bruno",
23510
+ "Cedric",
23511
+ "Clara",
23512
+ "Cosmo",
23513
+ "Dahlia",
23514
+ "Dexter",
23515
+ "Edie",
23516
+ "Elio",
23517
+ "Ember",
23518
+ "Felix",
23519
+ "Fern",
23520
+ "Flora",
23521
+ "Gus",
23522
+ "Hazel",
23523
+ "Hugo",
23524
+ "Ida",
23525
+ "Ingrid",
23526
+ "Iris",
23527
+ "Jasper",
23528
+ "Juno",
23529
+ "Kit",
23530
+ "Leo",
23531
+ "Lila",
23532
+ "Linus",
23533
+ "Lotte",
23534
+ "Mabel",
23535
+ "Marlow",
23536
+ "Milo",
23537
+ "Minnie",
23538
+ "Nell",
23539
+ "Nico",
23540
+ "Nina",
23541
+ "Odette",
23542
+ "Olive",
23543
+ "Oscar",
23544
+ "Otto",
23545
+ "Pearl",
23546
+ "Percy",
23547
+ "Pip",
23548
+ "Quill",
23549
+ "Remy",
23550
+ "Rosa",
23551
+ "Rufus",
23552
+ "Sable",
23553
+ "Silas",
23554
+ "Sky",
23555
+ "Sol",
23556
+ "Tess",
23557
+ "Theo",
23558
+ "Tilly",
23559
+ "Ulla",
23560
+ "Vera",
23561
+ "Vito",
23562
+ "Wren",
23563
+ "Xavi",
23564
+ "Yara",
23565
+ "Zed",
23566
+ "Zora",
23567
+ "Arlo",
23568
+ "Bea",
23569
+ "Cyrus",
23570
+ "Della",
23571
+ "Enzo",
23572
+ "Frida",
23573
+ "Gilda",
23574
+ "Hal",
23575
+ "Imre",
23576
+ "Joss",
23577
+ "Kai",
23578
+ "Lena",
23579
+ "Mona",
23580
+ "Nate",
23581
+ "Opal",
23582
+ "Pia",
23583
+ "Rae",
23584
+ "Sacha",
23585
+ "Tove",
23586
+ "Uma",
23587
+ "Vince",
23588
+ "Willa",
23589
+ "Yuki",
23590
+ "Zelda",
23591
+ "Ansel",
23592
+ "Birdie",
23593
+ "Cleo",
23594
+ "Dov",
23595
+ "Esme",
23596
+ "Fitz",
23597
+ "Greta",
23598
+ "Hollis",
23599
+ "Inez",
23600
+ "Jude",
23601
+ "Kip",
23602
+ "Lark"
23603
+ ];
23604
+ function agentNameFor(key) {
23605
+ let hash2 = 2166136261;
23606
+ for (let index = 0; index < key.length; index += 1) {
23607
+ hash2 ^= key.charCodeAt(index);
23608
+ hash2 = Math.imul(hash2, 16777619) >>> 0;
23609
+ }
23610
+ return AGENT_NAMES[hash2 % AGENT_NAMES.length] ?? AGENT_NAMES[0];
23611
+ }
23612
+
23465
23613
  // ../contract/src/work/bindings.ts
23466
23614
  var RepoInfo = external_exports.object({
23467
23615
  /**
@@ -23520,7 +23668,7 @@ function resolveSession() {
23520
23668
 
23521
23669
  // src/platform/session-file.ts
23522
23670
  import { createHash } from "crypto";
23523
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
23671
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
23524
23672
  import { dirname as dirname2, join as join3 } from "path";
23525
23673
  var sessionsDir = () => join3(dirname2(configPath()), "sessions");
23526
23674
  var realCwd = (cwd) => {
@@ -23540,6 +23688,21 @@ function rememberedSession(cwd) {
23540
23688
  const path3 = sessionFile(cwd);
23541
23689
  return existsSync3(path3) ? readFileSync3(path3, "utf8").trim() || null : null;
23542
23690
  }
23691
+ var conversationFile = (cwd) => join3(sessionsDir(), `${createHash("sha256").update(realCwd(cwd)).digest("hex").slice(0, 16)}.conversation.json`);
23692
+ function rememberConversation(cwd, conversation) {
23693
+ mkdirSync3(sessionsDir(), { recursive: true });
23694
+ writeFileSync3(conversationFile(cwd), JSON.stringify(conversation));
23695
+ }
23696
+ function rememberedConversation(cwd, notBefore) {
23697
+ const path3 = conversationFile(cwd);
23698
+ try {
23699
+ if (statSync(path3).mtimeMs < notBefore) return null;
23700
+ const parsed = JSON.parse(readFileSync3(path3, "utf8"));
23701
+ return typeof parsed.id === "string" && parsed.id ? { id: parsed.id, harness: parsed.harness ?? "other" } : null;
23702
+ } catch {
23703
+ return null;
23704
+ }
23705
+ }
23543
23706
 
23544
23707
  // src/platform/errors.ts
23545
23708
  var CliError = class extends Error {
@@ -23775,7 +23938,7 @@ This repository is linked to a Slopware Tasks board. Use the \`slop\` CLI to kee
23775
23938
  - Before starting tracked work: \`slop task list\`, then \`slop task start <KEY>\`.
23776
23939
  - When it is finished and verified: \`slop task done <KEY>\`.
23777
23940
  - Descriptions are Markdown and are shown in full on the board. Write real ones: context, file paths, what done means. Pipe long ones in with \`-d -\`.
23778
- - Agents use the six slop MCP tools for everything on the board (\`slop mcp install\` once per machine): \`board\`, \`task\`, \`plan\`, \`session\`, \`threads\`, \`say\`, each with an \`action\`. Messages about your task reach you after your next tool call. The \`slop\` command is for people and scripts. Talk through threads: \`slop task ask <KEY> "\u2026"\` reaches whoever is live on that task, \`slop thread reply <id> "\u2026"\` answers, \`slop threads\` lists them; general threads are for people and for context. Method: \`slop skill slop-sessions\`.
23941
+ - Agents use the six slop MCP tools for everything on the board (\`slop mcp install\` once per machine): \`board\`, \`task\`, \`plan\`, \`session\`, \`threads\`, \`say\`, each with an \`action\`. You have a name (session start tells you; \`session who\` lists everyone's); \`say post\` with \`to\` reaches one agent by name. Messages for you reach you after your next tool call. The \`slop\` command is for people and scripts. Talk through threads: \`slop task ask <KEY> "\u2026"\` reaches whoever is live on that task, \`slop thread reply <id> "\u2026"\` answers, \`slop threads\` lists them; general threads are for people and for context. Method: \`slop skill slop-sessions\`.
23779
23942
  - Work that outlasts one session gets a plan on its task: \`slop task plan <KEY> -\` (one item per line), then \`slop task item note|done <KEY> <n>\` as you go; \`slop task items <KEY>\` shows where you are. \`slop skill slop-planning\` is the method; \`slop skills install\` puts the skills in this repository.
23780
23943
  - Everything is editable: \`slop task update <KEY> --title \u2026 --severity \u2026 --status \u2026 --assignee \u2026\`. Assign with \`slop task assign <KEY> <email|me|none>\`; see who exists with \`slop members\`. \`slop task list --mine\` shows your user's tasks.
23781
23944
  - Add \`--json\` to any command for machine-readable output. \`slop task --help\` lists everything.
@@ -23805,6 +23968,10 @@ function findRepo() {
23805
23968
  const root = basename(commonDir) === ".git" ? resolve(commonDir, "..") : worktree ?? commonDir;
23806
23969
  return { root, worktree: worktree ?? root, linkPath: join5(commonDir, "slop.json") };
23807
23970
  }
23971
+ function currentBranch(cwd = process.cwd()) {
23972
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
23973
+ return branch && branch !== "HEAD" ? branch : null;
23974
+ }
23808
23975
  function requireRepo() {
23809
23976
  const repo = findRepo();
23810
23977
  if (!repo) throw new CliError("Not inside a git repository", "cd into your repository, or pass --project KEY", "no_repo");
@@ -23979,24 +24146,26 @@ linked to ${link.projectKey} ${link.projectName} (${link.baseUrl})`
23979
24146
  }
23980
24147
 
23981
24148
  // src/commands/mcp.ts
23982
- import { execFileSync as execFileSync3, spawn as spawn2 } from "child_process";
23983
- import { createHash as createHash4 } from "crypto";
23984
- import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync10, readdirSync, realpathSync as realpathSync2, rmSync as rmSync4, writeFileSync as writeFileSync7 } from "fs";
23985
- import { dirname as dirname5 } from "path";
23986
- import { homedir as homedir3 } from "os";
23987
- import { join as join7 } from "path";
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";
24151
+ import "path";
24152
+ import { homedir as homedir4 } from "os";
24153
+ import { join as join12 } from "path";
23988
24154
 
23989
24155
  // src/platform/live.ts
24156
+ var PING_EVERY_MS = 25e3;
24157
+ var PONG_WITHIN_MS = 1e4;
23990
24158
  async function waitForMessage(api, projectId, taskKey, options = {}) {
23991
- const ticket = await api.post(`/projects/${projectId}/tasks/${encodeURIComponent(taskKey)}/session/ticket`);
24159
+ const ticket = await api.post(`/projects/${projectId}/tasks/${encodeURIComponent(taskKey)}/session/ticket`, options.claim ?? {});
23992
24160
  const socket = new WebSocket(`${api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
23993
- const outcome = await new Promise((resolve2) => {
23994
- const timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => resolve2({ kind: "timeout" }), options.timeoutMs) : null;
24161
+ const outcome = await new Promise((resolve5) => {
24162
+ const timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => resolve5({ kind: "timeout" }), options.timeoutMs) : null;
23995
24163
  const done = (result) => {
23996
24164
  if (timer) clearTimeout(timer);
23997
- resolve2(result);
24165
+ resolve5(result);
23998
24166
  };
23999
24167
  socket.addEventListener("message", (event) => {
24168
+ if (String(event.data) === "pong") return;
24000
24169
  const push = JSON.parse(String(event.data));
24001
24170
  if (push.type === "hello") {
24002
24171
  api.sessionId = push.session.id;
@@ -24018,28 +24187,33 @@ async function waitForMessage(api, projectId, taskKey, options = {}) {
24018
24187
  if (outcome.kind !== "message") rememberSession(process.cwd(), null);
24019
24188
  if (outcome.kind === "message" || outcome.kind === "timeout") {
24020
24189
  socket.close(1e3, outcome.kind === "message" ? "handled" : "timeout");
24021
- await new Promise((resolve2) => {
24022
- socket.addEventListener("close", () => resolve2(), { once: true });
24023
- setTimeout(resolve2, 500);
24190
+ await new Promise((resolve5) => {
24191
+ socket.addEventListener("close", () => resolve5(), { once: true });
24192
+ setTimeout(resolve5, 500);
24024
24193
  });
24025
24194
  }
24026
24195
  return outcome;
24027
24196
  }
24028
24197
  var HeldSession = class {
24029
- constructor(api, projectId, taskKey, onMessage) {
24198
+ constructor(api, projectId, taskKey, hooks) {
24030
24199
  this.api = api;
24031
24200
  this.projectId = projectId;
24032
24201
  this.taskKey = taskKey;
24033
- this.onMessage = onMessage;
24202
+ this.hooks = hooks;
24034
24203
  }
24035
24204
  api;
24036
24205
  projectId;
24037
24206
  taskKey;
24038
- onMessage;
24207
+ hooks;
24039
24208
  socket = null;
24040
24209
  stopped = false;
24041
24210
  timer = null;
24211
+ heartbeat = null;
24212
+ pongDue = null;
24042
24213
  attempt = 0;
24214
+ connected = 0;
24215
+ session = null;
24216
+ /** Whoever is sitting in `session wait`. Handed the next message, or null when standby is switched off under them. */
24043
24217
  waiters = /* @__PURE__ */ new Set();
24044
24218
  async start() {
24045
24219
  await this.connect();
@@ -24047,30 +24221,49 @@ var HeldSession = class {
24047
24221
  async connect() {
24048
24222
  if (this.stopped) return;
24049
24223
  try {
24050
- const ticket = await this.api.post(this.taskKey ? `/projects/${this.projectId}/tasks/${encodeURIComponent(this.taskKey)}/session/ticket` : `/projects/${this.projectId}/session/ticket`);
24224
+ const ticket = await this.api.post(this.taskKey ? `/projects/${this.projectId}/tasks/${encodeURIComponent(this.taskKey)}/session/ticket` : `/projects/${this.projectId}/session/ticket`, this.hooks.claim());
24051
24225
  const socket = new WebSocket(`${this.api.baseUrl.replace(/^http/, "ws")}/api${ticket.path}`);
24052
24226
  this.socket = socket;
24053
24227
  socket.addEventListener("open", () => {
24054
24228
  this.attempt = 0;
24229
+ this.startHeartbeat(socket);
24055
24230
  });
24056
24231
  socket.addEventListener("message", (event) => {
24057
- const push = JSON.parse(String(event.data));
24232
+ const data = String(event.data);
24233
+ if (data === "pong") {
24234
+ if (this.pongDue) clearTimeout(this.pongDue);
24235
+ this.pongDue = null;
24236
+ return;
24237
+ }
24238
+ const push = JSON.parse(data);
24058
24239
  if (push.type === "hello") {
24059
24240
  this.api.sessionId = push.session.id;
24241
+ this.session = push.session;
24242
+ this.taskKey = push.session.taskKey;
24060
24243
  rememberSession(process.cwd(), push.session.id);
24244
+ this.connected += 1;
24245
+ if (this.connected > 1) void this.hooks.onReconnect?.(push.session);
24061
24246
  return;
24062
24247
  }
24063
24248
  if (push.type === "end") {
24064
24249
  this.stopped = true;
24065
24250
  return;
24066
24251
  }
24252
+ if (push.type === "reachable") {
24253
+ if (this.session) this.session = { ...this.session, reachable: push.on };
24254
+ if (!push.on) for (const waiter of [...this.waiters]) waiter(null);
24255
+ this.hooks.onReachable?.(push.on);
24256
+ return;
24257
+ }
24067
24258
  if (push.type !== "thread") return;
24068
24259
  const delivered = { thread: push.thread, message: push.message };
24069
- for (const waiter of this.waiters) waiter(delivered);
24070
- if (this.waiters.size === 0) this.onMessage(delivered);
24260
+ const taken = this.waiters.size > 0;
24261
+ for (const waiter of [...this.waiters]) waiter(delivered);
24262
+ if (!taken) this.hooks.onMessage(delivered);
24071
24263
  });
24072
24264
  socket.addEventListener("close", () => {
24073
- this.socket = null;
24265
+ this.stopHeartbeat();
24266
+ if (this.socket === socket) this.socket = null;
24074
24267
  if (!this.stopped) this.schedule();
24075
24268
  });
24076
24269
  socket.addEventListener("error", () => {
@@ -24079,6 +24272,26 @@ var HeldSession = class {
24079
24272
  this.schedule();
24080
24273
  }
24081
24274
  }
24275
+ /** A ping every 25s; no pong within 10s means the socket is dead however open it looks. Close it and come back. */
24276
+ startHeartbeat(socket) {
24277
+ this.stopHeartbeat();
24278
+ this.heartbeat = setInterval(() => {
24279
+ if (socket.readyState !== WebSocket.OPEN) return;
24280
+ socket.send("ping");
24281
+ if (!this.pongDue) {
24282
+ this.pongDue = setTimeout(() => {
24283
+ this.pongDue = null;
24284
+ socket.close(4e3, "no pong");
24285
+ }, PONG_WITHIN_MS);
24286
+ }
24287
+ }, PING_EVERY_MS);
24288
+ }
24289
+ stopHeartbeat() {
24290
+ if (this.heartbeat) clearInterval(this.heartbeat);
24291
+ if (this.pongDue) clearTimeout(this.pongDue);
24292
+ this.heartbeat = null;
24293
+ this.pongDue = null;
24294
+ }
24082
24295
  schedule() {
24083
24296
  this.attempt += 1;
24084
24297
  this.timer = setTimeout(() => void this.connect(), Math.min(3e4, 1e3 * 2 ** Math.min(this.attempt, 5)));
@@ -24088,10 +24301,335 @@ var HeldSession = class {
24088
24301
  this.api.sessionId = null;
24089
24302
  rememberSession(process.cwd(), null);
24090
24303
  if (this.timer) clearTimeout(this.timer);
24304
+ this.stopHeartbeat();
24091
24305
  this.socket?.close(1e3, "released");
24092
24306
  }
24093
24307
  };
24094
24308
 
24309
+ // src/platform/agent-identity.ts
24310
+ import { randomUUID as randomUUID4 } from "crypto";
24311
+ import { basename as basename3, resolve as resolve3 } from "path";
24312
+
24313
+ // src/platform/harness.ts
24314
+ import { execFileSync as execFileSync4 } from "child_process";
24315
+ import { closeSync, openSync, readdirSync as readdirSync2, readSync, realpathSync as realpathSync2, statSync as statSync3 } from "fs";
24316
+ import { basename as basename2, join as join8 } from "path";
24317
+
24318
+ // src/platform/harness-wake.ts
24319
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
24320
+ import { join as join7 } from "path";
24321
+ import { connect } from "net";
24322
+ import { spawn as spawn2 } from "child_process";
24323
+ import { randomUUID as randomUUID3 } from "crypto";
24324
+
24325
+ // src/platform/homes.ts
24326
+ import { execFileSync as execFileSync3 } from "child_process";
24327
+ import { existsSync as existsSync6, readdirSync, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
24328
+ import { homedir as homedir3 } from "os";
24329
+ import { join as join6, resolve as resolve2 } from "path";
24330
+ var unique = (paths) => [...new Set(paths.map((path3) => resolve2(path3)))];
24331
+ function environmentsSeen() {
24332
+ if (process.platform === "win32") return [];
24333
+ const seen = [];
24334
+ try {
24335
+ if (process.platform === "linux") {
24336
+ let pid2 = process.ppid;
24337
+ for (let hop = 0; hop < 6 && pid2 > 1; hop += 1) {
24338
+ seen.push(readFileSync6(`/proc/${pid2}/environ`, "latin1").replace(/\0/g, " "));
24339
+ const stat = readFileSync6(`/proc/${pid2}/stat`, "utf8");
24340
+ pid2 = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
24341
+ }
24342
+ for (const entry of readdirSync("/proc")) {
24343
+ if (!/^\d+$/.test(entry)) continue;
24344
+ try {
24345
+ seen.push(readFileSync6(`/proc/${entry}/environ`, "latin1").replace(/\0/g, " "));
24346
+ } catch {
24347
+ }
24348
+ }
24349
+ return seen;
24350
+ }
24351
+ const ps = (args) => execFileSync3("ps", args, { encoding: "utf8", maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "ignore"] });
24352
+ let pid = process.ppid;
24353
+ for (let hop = 0; hop < 6 && pid > 1; hop += 1) {
24354
+ seen.push(ps(["eww", "-o", "command=", "-p", String(pid)]));
24355
+ pid = Number(ps(["-o", "ppid=", "-p", String(pid)]).trim());
24356
+ }
24357
+ seen.push(ps(["axeww", "-o", "command="]));
24358
+ } catch {
24359
+ }
24360
+ return seen;
24361
+ }
24362
+ var environments = null;
24363
+ function homesInUse(variable) {
24364
+ environments ??= environmentsSeen();
24365
+ const found = [];
24366
+ const pattern = new RegExp(`(?:^|\\s)${variable}=(\\S+)`, "g");
24367
+ for (const text of environments) for (const match of text.matchAll(pattern)) found.push(match[1] ?? "");
24368
+ return found.filter(Boolean);
24369
+ }
24370
+ var directoriesNamed = (root, prefix) => {
24371
+ try {
24372
+ return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.name.startsWith(prefix) && (entry.isDirectory() || entry.isSymbolicLink())).map((entry) => join6(root, entry.name)).filter((path3) => {
24373
+ try {
24374
+ return statSync2(path3).isDirectory();
24375
+ } catch {
24376
+ return false;
24377
+ }
24378
+ });
24379
+ } catch {
24380
+ return [];
24381
+ }
24382
+ };
24383
+ var hasAny = (dir2, files) => files.some((file3) => existsSync6(join6(dir2, file3)));
24384
+ function claudeHomes(root = homedir3(), inUse = homesInUse) {
24385
+ if (process.env.SLOP_CLAUDE_HOME) return [process.env.SLOP_CLAUDE_HOME];
24386
+ 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"]))]);
24388
+ }
24389
+ function codexHomes(root = homedir3(), inUse = homesInUse) {
24390
+ if (process.env.SLOP_CODEX_HOME) return [process.env.SLOP_CODEX_HOME];
24391
+ 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"]))]);
24393
+ }
24394
+ function ownHarnessHome(variable) {
24395
+ if (process.platform === "win32") return null;
24396
+ try {
24397
+ let pid = process.ppid;
24398
+ for (let hop = 0; hop < 6 && pid > 1; hop += 1) {
24399
+ const text = process.platform === "linux" ? readFileSync6(`/proc/${pid}/environ`, "latin1").replace(/\0/g, " ") : execFileSync3("ps", ["eww", "-o", "command=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
24400
+ const match = new RegExp(`(?:^|\\s)${variable}=(\\S+)`).exec(text);
24401
+ if (match?.[1]) return resolve2(match[1]);
24402
+ if (process.platform === "linux") {
24403
+ const stat = readFileSync6(`/proc/${pid}/stat`, "utf8");
24404
+ pid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
24405
+ } else pid = Number(execFileSync3("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim());
24406
+ }
24407
+ } catch {
24408
+ }
24409
+ return null;
24410
+ }
24411
+
24412
+ // src/platform/harness-wake.ts
24413
+ function harnessRegistration(pid = process.ppid) {
24414
+ for (const home of claudeHomes()) {
24415
+ const path3 = join7(home, "sessions", `${pid}.json`);
24416
+ if (!existsSync7(path3)) continue;
24417
+ try {
24418
+ return JSON.parse(readFileSync7(path3, "utf8"));
24419
+ } catch {
24420
+ return null;
24421
+ }
24422
+ }
24423
+ return null;
24424
+ }
24425
+ function wakeHarness(text, pid = Number(process.env.SLOP_HARNESS_PID) || process.ppid) {
24426
+ const registration = harnessRegistration(pid);
24427
+ if (!registration?.sessionId || !registration.messagingSocketPath) return Promise.resolve(false);
24428
+ const frame = {
24429
+ uuid: randomUUID3(),
24430
+ session_id: registration.sessionId,
24431
+ type: "user",
24432
+ parent_tool_use_id: null,
24433
+ message: { role: "user", content: text }
24434
+ };
24435
+ return new Promise((resolve5) => {
24436
+ const socket = connect({ path: registration.messagingSocketPath });
24437
+ const done = (ok) => {
24438
+ clearTimeout(timer);
24439
+ socket.destroy();
24440
+ resolve5(ok);
24441
+ };
24442
+ const timer = setTimeout(() => done(false), 3e3);
24443
+ socket.on("connect", () => {
24444
+ socket.write(`${JSON.stringify(frame)}
24445
+ `, (error62) => error62 ? done(false) : setTimeout(() => done(true), 200));
24446
+ });
24447
+ socket.on("error", () => done(false));
24448
+ });
24449
+ }
24450
+ function wakeCodex(text, threadId) {
24451
+ const endpoint = process.env.SLOP_CODEX_APP_SERVER;
24452
+ if (endpoint) return wakeCodexOverWs(text, threadId, endpoint);
24453
+ return Promise.resolve(false);
24454
+ }
24455
+ function wakeCodexOverWs(text, threadId, endpoint) {
24456
+ return new Promise((resolve5) => {
24457
+ let socket;
24458
+ try {
24459
+ socket = new WebSocket(endpoint);
24460
+ } catch {
24461
+ resolve5(false);
24462
+ return;
24463
+ }
24464
+ let nextId = 1;
24465
+ const pending = /* @__PURE__ */ new Map();
24466
+ const finish = (ok) => {
24467
+ clearTimeout(timer);
24468
+ try {
24469
+ socket.close();
24470
+ } catch {
24471
+ }
24472
+ resolve5(ok);
24473
+ };
24474
+ const timer = setTimeout(() => finish(false), 8e3);
24475
+ const call = (method, params) => new Promise((ok, fail2) => {
24476
+ const id = nextId++;
24477
+ pending.set(id, (message) => message.error ? fail2(new Error(method)) : ok(message.result));
24478
+ socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
24479
+ });
24480
+ socket.addEventListener("error", () => finish(false));
24481
+ socket.addEventListener("message", (event) => {
24482
+ try {
24483
+ const message = JSON.parse(String(event.data));
24484
+ if (typeof message.id === "number") pending.get(message.id)?.(message);
24485
+ } catch {
24486
+ }
24487
+ });
24488
+ socket.addEventListener("open", () => {
24489
+ void (async () => {
24490
+ try {
24491
+ await call("initialize", { clientInfo: { name: "slop", version: "0" }, capabilities: { experimentalApi: true } });
24492
+ socket.send(JSON.stringify({ jsonrpc: "2.0", method: "initialized", params: {} }));
24493
+ const loaded = await call("thread/loaded/list", {});
24494
+ if (!loaded.data.includes(threadId)) {
24495
+ finish(false);
24496
+ return;
24497
+ }
24498
+ await call("turn/start", { threadId, input: [{ type: "text", text }] });
24499
+ finish(true);
24500
+ } catch {
24501
+ finish(false);
24502
+ }
24503
+ })();
24504
+ });
24505
+ });
24506
+ }
24507
+
24508
+ // src/platform/harness.ts
24509
+ var parent = null;
24510
+ function parentCommand() {
24511
+ if (parent !== null) return parent;
24512
+ try {
24513
+ parent = process.platform === "win32" ? "" : execFileSync4("ps", ["-o", "command=", "-p", String(process.ppid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
24514
+ } catch {
24515
+ parent = "";
24516
+ }
24517
+ return parent;
24518
+ }
24519
+ function harnessOf(command) {
24520
+ const first = basename2(command.trim().split(/\s+/)[0] ?? "");
24521
+ if (/^claude(\b|$)/.test(first)) return "claude";
24522
+ if (/^codex(\b|$)/.test(first)) return "codex";
24523
+ if (/(^|\/)codex(\S*)?\s/.test(command)) return "codex";
24524
+ return null;
24525
+ }
24526
+ function isNonInteractive(parentCommand2) {
24527
+ return /(^|[\s/])codex(\S*)?\s+exec(\s|$)/.test(parentCommand2);
24528
+ }
24529
+ function nonInteractiveHarness() {
24530
+ return isNonInteractive(parentCommand());
24531
+ }
24532
+ var same = (a, b) => {
24533
+ const real = (path3) => {
24534
+ try {
24535
+ return realpathSync2(path3);
24536
+ } catch {
24537
+ return path3;
24538
+ }
24539
+ };
24540
+ return real(a) === real(b);
24541
+ };
24542
+ function codexConversation(roots, cwd) {
24543
+ const rollouts = [];
24544
+ const walk = (dir2, depth) => {
24545
+ let entries;
24546
+ try {
24547
+ entries = readdirSync2(dir2, { withFileTypes: true });
24548
+ } catch {
24549
+ return;
24550
+ }
24551
+ for (const entry of entries) {
24552
+ const path3 = join8(dir2, entry.name);
24553
+ if (entry.isDirectory()) {
24554
+ if (depth < 3) walk(path3, depth + 1);
24555
+ continue;
24556
+ }
24557
+ const match = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(entry.name);
24558
+ if (!match) continue;
24559
+ try {
24560
+ rollouts.push({ path: path3, id: match[1] ?? "", mtime: statSync3(path3).mtimeMs });
24561
+ } catch {
24562
+ }
24563
+ }
24564
+ };
24565
+ for (const root of roots) walk(root, 0);
24566
+ rollouts.sort((a, b) => b.mtime - a.mtime);
24567
+ for (const rollout of rollouts.slice(0, 300)) {
24568
+ let head = "";
24569
+ try {
24570
+ const fd = openSync(rollout.path, "r");
24571
+ try {
24572
+ const buffer = Buffer.alloc(8192);
24573
+ head = buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8");
24574
+ } finally {
24575
+ closeSync(fd);
24576
+ }
24577
+ } catch {
24578
+ continue;
24579
+ }
24580
+ const line = head.split("\n")[0] ?? "";
24581
+ if (!line.includes('"session_meta"')) continue;
24582
+ const found = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(line);
24583
+ if (!found) continue;
24584
+ let dir2;
24585
+ try {
24586
+ dir2 = JSON.parse(`"${found[1]}"`);
24587
+ } catch {
24588
+ continue;
24589
+ }
24590
+ if (same(dir2, cwd)) return rollout.id;
24591
+ }
24592
+ return null;
24593
+ }
24594
+ function harnessConversation(cwd) {
24595
+ const harness = harnessOf(parentCommand());
24596
+ if (harness === "claude") {
24597
+ const id = harnessRegistration()?.sessionId;
24598
+ return id ? { id, harness } : null;
24599
+ }
24600
+ if (harness === "codex" && !nonInteractiveHarness()) {
24601
+ const id = codexConversation(codexHomes().map((home) => join8(home, "sessions")), cwd);
24602
+ return id ? { id, harness } : null;
24603
+ }
24604
+ return null;
24605
+ }
24606
+
24607
+ // src/platform/agent-identity.ts
24608
+ var processConversation = randomUUID4();
24609
+ var startedAt = Date.now();
24610
+ function knownConversation(cwd) {
24611
+ return harnessConversation(cwd) ?? rememberedConversation(cwd, startedAt - 12e4);
24612
+ }
24613
+ function agentIdentity(cwd, fallback = "process") {
24614
+ const conversation = knownConversation(cwd);
24615
+ const repo = findRepo();
24616
+ return {
24617
+ conversation: conversation?.id ?? (fallback === "process" ? processConversation : `dir:${resolve3(cwd)}`),
24618
+ harness: conversation?.harness ?? null,
24619
+ directory: repo ? basename3(repo.worktree) : basename3(cwd),
24620
+ branch: currentBranch(cwd)
24621
+ };
24622
+ }
24623
+
24624
+ // src/platform/bounded.ts
24625
+ var RESULT_CAP = 16e3;
24626
+ function bounded(result) {
24627
+ if (result.length <= RESULT_CAP) return result;
24628
+ return `${result.slice(0, RESULT_CAP)}
24629
+
24630
+ [cut: ${result.length - RESULT_CAP} more characters; ask for less with limit, offset or before]`;
24631
+ }
24632
+
24095
24633
  // src/platform/mcp.ts
24096
24634
  import { createInterface } from "readline";
24097
24635
  function serveMcp(tools2, info) {
@@ -24149,11 +24687,11 @@ function serveMcp(tools2, info) {
24149
24687
  }
24150
24688
 
24151
24689
  // src/commands/tasks.ts
24152
- import { readFileSync as readFileSync7 } from "fs";
24690
+ import { readFileSync as readFileSync9 } from "fs";
24153
24691
 
24154
24692
  // src/commands/items.ts
24155
- import { readFileSync as readFileSync6 } from "fs";
24156
- var readText = (value) => value === "-" ? readFileSync6(0, "utf8").trim() : value;
24693
+ import { readFileSync as readFileSync8 } from "fs";
24694
+ var readText = (value) => value === "-" ? readFileSync8(0, "utf8").trim() : value;
24157
24695
  var path2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}/items`;
24158
24696
  function renderItems(items) {
24159
24697
  if (items.length === 0) return "No plan yet. Write one: slop task plan <KEY> -";
@@ -24180,7 +24718,7 @@ function registerItemCommands(task, withProject) {
24180
24718
  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) => {
24181
24719
  const api = authedApi();
24182
24720
  const project = await resolveProject(api, options.project);
24183
- const source = lines.length === 1 && lines[0] === "-" ? readFileSync6(0, "utf8") : lines.join("\n");
24721
+ const source = lines.length === 1 && lines[0] === "-" ? readFileSync8(0, "utf8") : lines.join("\n");
24184
24722
  const titles = source.split("\n").map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s*(?:\[[ xX]\]\s*)?/, "").trim()).filter(Boolean);
24185
24723
  if (titles.length === 0) throw new CliError("Nothing to add", 'give items as arguments, or "-" and one per line on stdin');
24186
24724
  const items = await api.post(path2(project.id, key), { items: titles.map((title3) => ({ title: title3 })) });
@@ -24237,7 +24775,7 @@ function oneOf(value, allowed, what) {
24237
24775
  if (allowed.includes(normalized)) return normalized;
24238
24776
  throw new CliError(`Unknown ${what} "${value}"`, `use one of: ${allowed.join(", ")}`);
24239
24777
  }
24240
- var readDescription = (value) => value === "-" ? readFileSync7(0, "utf8").trim() : value;
24778
+ var readDescription = (value) => value === "-" ? readFileSync9(0, "utf8").trim() : value;
24241
24779
  var taskUrl = (baseUrl, projectId, task) => `${baseUrl}/projects/${projectId}?task=${task.key}`;
24242
24780
  var loadProject = (api, id) => api.get(`/projects/${id}`);
24243
24781
  function resolveStatus(project, ref) {
@@ -24388,14 +24926,14 @@ ${taskUrl(api.baseUrl, project.id, created)}`);
24388
24926
  }
24389
24927
 
24390
24928
  // src/platform/update-check.ts
24391
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
24392
- import { dirname as dirname4, join as join6 } from "path";
24929
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
24930
+ import { dirname as dirname4, join as join9 } from "path";
24393
24931
  var CHECK_EVERY_MS = 60 * 60 * 1e3;
24394
24932
  var REGISTRY = "https://registry.npmjs.org/slopwaresystems/latest";
24395
- var cacheFile = () => join6(dirname4(configPath()), "update-check.json");
24933
+ var cacheFile = () => join9(dirname4(configPath()), "update-check.json");
24396
24934
  function readCache() {
24397
24935
  try {
24398
- return JSON.parse(readFileSync8(cacheFile(), "utf8"));
24936
+ return JSON.parse(readFileSync10(cacheFile(), "utf8"));
24399
24937
  } catch {
24400
24938
  return { checkedAt: 0, latest: null };
24401
24939
  }
@@ -24431,15 +24969,15 @@ function updateNotice(current) {
24431
24969
  if (!latest || !isNewer(latest, current)) return null;
24432
24970
  return `Update available: slopwaresystems ${latest} (this is ${current}). Ask your person to run: npm i -g slopwaresystems && slop mcp install`;
24433
24971
  }
24434
- var updateChecksEnabled = () => !existsSync6(join6(dirname4(configPath()), "no-update-check")) && process.env.SLOP_NO_UPDATE_CHECK !== "1";
24972
+ var updateChecksEnabled = () => !existsSync8(join9(dirname4(configPath()), "no-update-check")) && process.env.SLOP_NO_UPDATE_CHECK !== "1";
24435
24973
 
24436
24974
  // src/commands/threads.ts
24437
- import { readFileSync as readFileSync9 } from "fs";
24438
- var readText2 = (value) => value === "-" ? readFileSync9(0, "utf8").trim() : value;
24975
+ import { readFileSync as readFileSync11 } from "fs";
24976
+ var readText2 = (value) => value === "-" ? readFileSync11(0, "utf8").trim() : value;
24439
24977
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
24440
- var who = (message) => `${message.author.name}${message.via === "agent" ? ` (agent${message.machine ? ` \xB7 ${message.machine}` : ""})` : ""}`;
24978
+ var who = (message) => message.via !== "agent" ? message.author.name : `${message.agent ?? message.author.name} (${message.agent ? `${message.author.name}'s agent` : "agent"}${message.machine ? ` \xB7 ${message.machine}` : ""})`;
24441
24979
  function threadLine(thread) {
24442
- const scope = thread.taskKey ?? "general";
24980
+ const scope = [thread.taskKey ?? (thread.to ? null : "general"), thread.to ? `to ${thread.to.agent}` : null].filter(Boolean).join(", ");
24443
24981
  const unread = thread.unread > 0 ? ` ${thread.unread} unread` : "";
24444
24982
  const last = thread.last ? `
24445
24983
  ${who(thread.last)}: ${thread.last.body.split("\n")[0]}` : "";
@@ -24453,9 +24991,10 @@ function threadBlock(thread) {
24453
24991
  return [`${thread.subject} [${thread.taskKey ?? "general"}] ${thread.id}`, "", ...thread.messages.map(messageBlock)].join("\n\n").replace("\n\n\n", "\n\n");
24454
24992
  }
24455
24993
  function pushBlock(push) {
24456
- return `Thread ${push.thread.id} [${push.thread.taskKey ?? "general"}] ${push.thread.subject}
24994
+ const scope = [push.thread.taskKey ?? (push.thread.to ? null : "general"), push.thread.to ? `to ${push.thread.to}` : null].filter(Boolean).join(", ");
24995
+ return `Thread ${push.thread.id} [${scope}] ${push.thread.subject}
24457
24996
  ${messageBlock(push.message)}
24458
- Reply: slop thread reply ${push.thread.id} "\u2026"`;
24997
+ Reply: say reply (thread ${push.thread.id})`;
24459
24998
  }
24460
24999
  function registerThreadCommands(program3, task) {
24461
25000
  const withProject = (command) => command.option("-p, --project <key>", "project key (default: the project this repository is linked to)");
@@ -24470,10 +25009,10 @@ function registerThreadCommands(program3, task) {
24470
25009
  emit(threads, () => threads.length === 0 ? "No threads." : threads.map(threadLine).join("\n"));
24471
25010
  });
24472
25011
  const thread = program3.command("thread").description("One thread: start, read, reply.");
24473
- withProject(thread.command("new <subject>")).description('Start a thread. General by default; --task makes it about a task (and reaches whoever is live on it). The first message is -m, or "-" for stdin.').requiredOption("-m, --message <text>", 'the first message; "-" reads stdin').option("-t, --task <key>", "about this task").action(async (subject, options) => {
25012
+ withProject(thread.command("new <subject>")).description('Start a thread. General by default; --task makes it about a task (and reaches whoever is live on it). The first message is -m, or "-" for stdin.').requiredOption("-m, --message <text>", 'the first message; "-" reads stdin').option("-t, --task <key>", "about this task").option("--to <agent>", "to one live agent, by name or session id (slop sessions)").action(async (subject, options) => {
24474
25013
  const api = authedApi();
24475
25014
  const project = await resolveProject(api, options.project);
24476
- const result = await api.post(`/projects/${project.id}/threads`, { subject, task: options.task, body: readText2(options.message) });
25015
+ const result = await api.post(`/projects/${project.id}/threads`, { subject, task: options.task, to: options.to, body: readText2(options.message) });
24477
25016
  emit(result, () => started(result));
24478
25017
  });
24479
25018
  withProject(thread.command("show <id>")).description("Read a thread (and mark it read).").action(async (id, options) => {
@@ -24492,40 +25031,33 @@ function registerThreadCommands(program3, task) {
24492
25031
  const api = authedApi();
24493
25032
  const project = await resolveProject(api, options.project);
24494
25033
  const body2 = readText2(text);
24495
- const result = await api.post(`/projects/${project.id}/threads`, { subject: subjectOf(body2), task: options.task, body: body2 });
25034
+ const result = await api.post(`/projects/${project.id}/threads`, { task: options.task, body: body2 });
24496
25035
  emit(result, () => started(result));
24497
25036
  });
24498
25037
  withProject(task.command("ask <key> <text>")).description('Start a thread about the task and say something to whoever is live on it. "-" reads stdin.').action(async (key, text, options) => {
24499
25038
  const api = authedApi();
24500
25039
  const project = await resolveProject(api, options.project);
24501
25040
  const body2 = readText2(text);
24502
- const result = await api.post(`/projects/${project.id}/threads`, { subject: subjectOf(body2), task: key, body: body2 });
25041
+ const result = await api.post(`/projects/${project.id}/threads`, { task: key, body: body2 });
24503
25042
  emit(result, () => started(result));
24504
25043
  });
24505
25044
  }
24506
- var subjectOf = (body2) => {
24507
- const line = body2.split("\n")[0]?.trim() ?? "";
24508
- if (!line) throw new CliError("Say something", "the first line becomes the subject");
24509
- return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
24510
- };
24511
- var started = (result) => result.thread.taskKey ? result.delivered ? `Started ${result.thread.id} on ${result.thread.taskKey}; reached ${result.delivered} live session${result.delivered === 1 ? "" : "s"}.` : `Started ${result.thread.id} on ${result.thread.taskKey}. Nobody is live on it; they see it when they start.` : `Started ${result.thread.id} (general). People see it on the web; agents at their next session start.`;
25045
+ var started = (result) => result.thread.to ? `Started ${result.thread.id} to ${result.thread.to.agent}${result.delivered ? "; it has it now." : "; it reads it when it is back."}` : result.thread.taskKey ? result.delivered ? `Started ${result.thread.id} on ${result.thread.taskKey}; reached ${result.delivered} live session${result.delivered === 1 ? "" : "s"}.` : `Started ${result.thread.id} on ${result.thread.taskKey}. Nobody is live on it; they see it when they start.` : `Started ${result.thread.id} (general). People see it on the web; agents at their next session start.`;
24512
25046
 
24513
- // src/commands/mcp.ts
24514
- var str = (description2) => ({ type: "string", description: description2 });
24515
- var int2 = (description2) => ({ type: "integer", description: description2 });
24516
- var schema = (properties, required2 = []) => ({ type: "object", properties, required: required2, additionalProperties: false });
24517
- var PROJECT = str("Project key. Defaults to the project this repository is linked to.");
24518
- var taskPath = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24519
- var inboxDir = () => join7(dirname5(configPath()), "inbox");
25047
+ // src/platform/inbox.ts
25048
+ import { createHash as createHash4 } from "crypto";
25049
+ import { appendFileSync, existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync12, realpathSync as realpathSync3, renameSync, rmSync as rmSync4 } from "fs";
25050
+ import { dirname as dirname5, join as join10 } from "path";
25051
+ var inboxDir = () => join10(dirname5(configPath()), "inbox");
24520
25052
  var realCwd2 = (cwd) => {
24521
25053
  try {
24522
- return realpathSync2(cwd);
25054
+ return realpathSync3(cwd);
24523
25055
  } catch {
24524
25056
  return cwd;
24525
25057
  }
24526
25058
  };
24527
25059
  var inboxKey = (cwd) => createHash4("sha256").update(realCwd2(cwd)).digest("hex").slice(0, 16);
24528
- var inboxFile = (cwd) => join7(inboxDir(), `${inboxKey(cwd)}.jsonl`);
25060
+ var inboxFile = (cwd) => join10(inboxDir(), `${inboxKey(cwd)}.jsonl`);
24529
25061
  function inboxWrite(cwd, push) {
24530
25062
  mkdirSync6(inboxDir(), { recursive: true });
24531
25063
  appendFileSync(inboxFile(cwd), `${JSON.stringify(push)}
@@ -24533,11 +25065,58 @@ function inboxWrite(cwd, push) {
24533
25065
  }
24534
25066
  function inboxDrain(cwd) {
24535
25067
  const path3 = inboxFile(cwd);
24536
- if (!existsSync7(path3)) return [];
24537
- const lines = readFileSync10(path3, "utf8").split("\n").filter(Boolean);
24538
- rmSync4(path3, { force: true });
24539
- return lines.map((line) => JSON.parse(line));
25068
+ if (!existsSync9(path3)) return [];
25069
+ const taken = `${path3}.${process.pid}.${Date.now()}.draining`;
25070
+ try {
25071
+ renameSync(path3, taken);
25072
+ } catch {
25073
+ return [];
25074
+ }
25075
+ const lines = readFileSync12(taken, "utf8").split("\n").filter(Boolean);
25076
+ rmSync4(taken, { force: true });
25077
+ return lines.flatMap((line) => {
25078
+ try {
25079
+ return [JSON.parse(line)];
25080
+ } catch {
25081
+ return [];
25082
+ }
25083
+ });
24540
25084
  }
25085
+
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
+ // src/commands/mcp.ts
25104
+ var str = (description2) => ({ type: "string", description: description2 });
25105
+ var int2 = (description2) => ({ type: "integer", description: description2 });
25106
+ var schema = (properties, required2 = []) => ({ type: "object", properties, required: required2, additionalProperties: false });
25107
+ var PROJECT = str("Project key. Defaults to the project this repository is linked to.");
25108
+ var taskPath = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
25109
+ var sessionLine = (session, mine = null) => [
25110
+ // Optional chaining on purpose: a server from before agents had names answers without these, and a tool must not crash on it.
25111
+ (session.agent?.name ?? "agent") + (session.id === mine ? " (you)" : ""),
25112
+ session.taskKey ?? "(no task)",
25113
+ session.user.name,
25114
+ session.machine.name,
25115
+ session.where?.directory ? `${session.where.directory}${session.where.branch ? `@${session.where.branch}` : ""}` : "-",
25116
+ `since ${session.startedAt}`,
25117
+ session.state === "reconnecting" ? "(reconnecting)" : "",
25118
+ session.reachable ? "" : "(not visible to teammates)"
25119
+ ].filter(Boolean).join(" ");
24541
25120
  async function digest(api, projectId, taskKey) {
24542
25121
  const [about, mine, general] = await Promise.all([
24543
25122
  taskKey ? api.get(`/projects/${projectId}/threads?task=${encodeURIComponent(taskKey)}&unread=1&read=1`) : Promise.resolve([]),
@@ -24551,14 +25130,48 @@ async function digest(api, projectId, taskKey) {
24551
25130
  if (general.length) lines.push("Unread general threads (context; nobody has to answer):", ...general.map(threadLine));
24552
25131
  return lines.length ? lines.join("\n") : "Nothing unread.";
24553
25132
  }
25133
+ async function catchUp(api, projectId, taskKey, cwd) {
25134
+ const [mine, about] = await Promise.all([
25135
+ api.get(`/projects/${projectId}/threads?participant=1&unread=1`),
25136
+ taskKey ? api.get(`/projects/${projectId}/threads?task=${encodeURIComponent(taskKey)}&unread=1`) : Promise.resolve([])
25137
+ ]);
25138
+ const seen = /* @__PURE__ */ new Set();
25139
+ for (const thread of [...about, ...mine]) {
25140
+ if (seen.has(thread.id) || thread.unread === 0) continue;
25141
+ seen.add(thread.id);
25142
+ const detail = await api.get(`/projects/${projectId}/threads/${thread.id}?read=0`);
25143
+ for (const message of detail.messages.slice(-thread.unread)) inboxWrite(cwd, { thread: { id: detail.id, subject: detail.subject, taskKey: detail.taskKey, to: detail.to?.agent ?? null }, message });
25144
+ }
25145
+ }
24554
25146
  function handlers() {
24555
25147
  const api = authedApi();
24556
25148
  const resolveOnly = (args) => resolveProject(api, args.project);
24557
25149
  let held = null;
25150
+ let identity = null;
25151
+ const whoAmI = () => identity ??= agentIdentity(process.cwd());
24558
25152
  const hold = async (projectId, taskKey) => {
24559
25153
  if (held && held.projectId === projectId && held.taskKey === taskKey) return;
24560
25154
  held?.stop();
24561
- held = new HeldSession(api, projectId, taskKey, (push) => inboxWrite(process.cwd(), push));
25155
+ held = new HeldSession(api, projectId, taskKey, {
25156
+ // A message for the agent wakes its harness when the harness can be woken (Claude Code's session socket): it
25157
+ // arrives as a turn, now, whether the agent is idle or mid-turn. Otherwise the inbox and the hooks carry it.
25158
+ onMessage: (push) => {
25159
+ const text = `A message for you on Slopware Tasks. Act or answer it (say reply), then carry on.
25160
+
25161
+ ${pushBlock(push)}`;
25162
+ const me = whoAmI();
25163
+ const wake = me.harness === "codex" && me.conversation ? wakeCodex(text, me.conversation) : wakeHarness(text);
25164
+ void wake.then((woken) => {
25165
+ if (!woken) inboxWrite(process.cwd(), push);
25166
+ });
25167
+ },
25168
+ 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
+ });
24562
25175
  await held.start();
24563
25176
  };
24564
25177
  const project = async (args) => {
@@ -24569,29 +25182,47 @@ function handlers() {
24569
25182
  const release = async () => {
24570
25183
  if (held?.taskKey) await hold(held.projectId, null);
24571
25184
  };
25185
+ if (!nonInteractiveHarness())
25186
+ void (async () => {
25187
+ for (let waited = 0; waited < 1e4 && !knownConversation(process.cwd()); waited += 250) await new Promise((resolve5) => setTimeout(resolve5, 250));
25188
+ whoAmI();
25189
+ const target = await resolveOnly({});
25190
+ if (!held) await hold(target.id, null);
25191
+ })().catch(() => void 0);
25192
+ if (!nonInteractiveHarness()) void healCodexTrust().catch(() => void 0);
25193
+ const goodbye = () => {
25194
+ held?.stop();
25195
+ held = null;
25196
+ setTimeout(() => process.exit(0), 300).unref();
25197
+ };
25198
+ process.stdin.once("end", goodbye);
25199
+ for (const signal of ["SIGTERM", "SIGHUP", "SIGINT"]) process.once(signal, goodbye);
24572
25200
  return [
24573
25201
  {
24574
25202
  name: "wait_for_message",
24575
- description: "Optional. Block until the next message in a thread about the task you hold (claim_task first), or until `minutes` pass. You do not need this to be reached: messages arrive on their own after your next tool call. Use it only when you have nothing else to do and want to sit and wait.",
24576
- inputSchema: schema({ task: str("Task key, e.g. WEB-12"), minutes: int2("How long to wait before returning empty (default 10)"), project: PROJECT }, ["task"]),
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 }),
24577
25205
  run: async (args) => {
24578
25206
  const target = await project(args);
24579
- await hold(target.id, String(args.task));
25207
+ if (args.task) await hold(target.id, String(args.task));
24580
25208
  const queued = inboxDrain(process.cwd());
24581
25209
  if (queued.length) return queued.map(pushBlock).join("\n\n");
24582
- const minutes = typeof args.minutes === "number" ? args.minutes : 10;
24583
- const push = await new Promise((resolve2) => {
25210
+ const minutes = typeof args.minutes === "number" ? args.minutes : 300;
25211
+ const outcome = await new Promise((resolve5) => {
24584
25212
  const waiter = (delivered) => {
24585
25213
  held?.waiters.delete(waiter);
24586
- resolve2(delivered);
25214
+ clearTimeout(timer);
25215
+ resolve5({ push: delivered, released: delivered === null });
24587
25216
  };
24588
25217
  held?.waiters.add(waiter);
24589
- setTimeout(() => {
25218
+ const timer = setTimeout(() => {
24590
25219
  held?.waiters.delete(waiter);
24591
- resolve2(null);
25220
+ resolve5({ push: null, released: false });
24592
25221
  }, minutes * 6e4);
24593
25222
  });
24594
- return push ? pushBlock(push) : `No message in ${minutes} minutes.`;
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.`;
24595
25226
  }
24596
25227
  },
24597
25228
  {
@@ -24618,11 +25249,11 @@ function handlers() {
24618
25249
  {
24619
25250
  name: "post",
24620
25251
  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.",
24621
- inputSchema: schema({ subject: str("Subject"), body: str("First message (Markdown)"), task: str("Task key, to make it about that task"), project: PROJECT }, ["subject", "body"]),
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"]),
24622
25253
  run: async (args) => {
24623
25254
  const target = await project(args);
24624
- const result = await api.post(`/projects/${target.id}/threads`, { subject: String(args.subject), body: String(args.body), task: args.task });
24625
- return `Started ${result.thread.id}${result.thread.taskKey ? ` on ${result.thread.taskKey}` : " (general)"}${result.delivered ? `; reached ${result.delivered} live session(s)` : ""}.`;
25255
+ 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)` : ""}.`;
24626
25257
  }
24627
25258
  },
24628
25259
  {
@@ -24642,7 +25273,15 @@ function handlers() {
24642
25273
  name: "thread",
24643
25274
  description: "Read a thread in full (marks it read).",
24644
25275
  inputSchema: schema({ id: str("Thread id"), project: PROJECT }, ["id"]),
24645
- run: async (args) => threadBlock(await api.get(`/projects/${(await project(args)).id}/threads/${String(args.id)}`))
25276
+ run: async (args) => {
25277
+ const query = new URLSearchParams({ limit: String(Math.max(1, Number(args.limit ?? 40))) });
25278
+ if (args.before) query.set("before", String(args.before));
25279
+ const detail = await api.get(`/projects/${(await project(args)).id}/threads/${String(args.id)}?${query}`);
25280
+ const earlier = detail.earlier > 0 && detail.messages[0] ? `\u2026 ${detail.earlier} earlier (before: ${detail.messages[0].createdAt})
25281
+
25282
+ ` : "";
25283
+ return earlier + threadBlock(detail);
25284
+ }
24646
25285
  },
24647
25286
  {
24648
25287
  name: "sessions",
@@ -24650,7 +25289,8 @@ function handlers() {
24650
25289
  inputSchema: schema({ project: PROJECT }),
24651
25290
  run: async (args) => {
24652
25291
  const list = await api.get(`/projects/${(await project(args)).id}/sessions`);
24653
- return list.length ? list.map((session) => `${session.taskKey ?? "(no task)"} ${session.user.name} ${session.machine.name} since ${session.startedAt}`).join("\n") : "Nobody is live.";
25292
+ const mine = held?.session?.id ?? null;
25293
+ return list.length ? list.map((session) => sessionLine(session, mine)).join("\n") : "Nobody is live.";
24654
25294
  }
24655
25295
  },
24656
25296
  {
@@ -24730,7 +25370,15 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
24730
25370
  const doneKeys = new Set(target.statuses.filter((status) => status.category === "done").map((status) => status.key));
24731
25371
  const only = args.status ? resolveStatus(target, String(args.status)) : null;
24732
25372
  const rows = list.filter((task) => (args.all || args.archived || !doneKeys.has(task.status)) && (!me || task.assignee?.userId === me) && (!only || task.status === only));
24733
- return rows.length ? rows.map((task) => `${task.key} ${target.statuses.find((status) => status.key === task.status)?.label ?? task.status} ${task.severity} ${task.assignee?.name ?? "-"} ${task.title}${task.progress.total ? ` (${task.progress.done}/${task.progress.total})` : ""}`).join("\n") : "No tasks.";
25373
+ const offset = Math.max(0, Number(args.offset ?? 0));
25374
+ const limit = Math.max(1, Number(args.limit ?? 50));
25375
+ const page = rows.slice(offset, offset + limit);
25376
+ const more = rows.length - offset - page.length;
25377
+ if (rows.length === 0) return "No tasks.";
25378
+ return [
25379
+ ...page.map((task) => `${task.key} ${target.statuses.find((status) => status.key === task.status)?.label ?? task.status} ${task.severity} ${task.assignee?.name ?? "-"} ${task.title}${task.progress.total ? ` (${task.progress.done}/${task.progress.total})` : ""}`),
25380
+ ...more > 0 ? [`\u2026 ${more} more (offset: ${offset + page.length})`] : []
25381
+ ].join("\n");
24734
25382
  }
24735
25383
  },
24736
25384
  {
@@ -24805,8 +25453,13 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
24805
25453
  inputSchema: schema({ task: str("Task key"), project: PROJECT }, ["task"]),
24806
25454
  run: async (args) => {
24807
25455
  const target = await project(args);
24808
- const events = await api.get(`${taskPath(target.id, String(args.task))}/events`);
24809
- return events.length ? events.map((record2) => `${record2.createdAt.slice(0, 16).replace("T", " ")} ${record2.actor.name} ${JSON.stringify(record2.event)}`).join("\n") : "No history yet.";
25456
+ const query = new URLSearchParams({ limit: String(Math.max(1, Number(args.limit ?? 50))) });
25457
+ if (args.before) query.set("before", String(args.before));
25458
+ const events = await api.get(`${taskPath(target.id, String(args.task))}/events?${query}`);
25459
+ if (events.length === 0) return "No history yet.";
25460
+ const lines = events.map((record2) => `${record2.createdAt.slice(0, 16).replace("T", " ")} ${record2.actor.name} ${JSON.stringify(record2.event)}`);
25461
+ const full = events.length >= Number(query.get("limit"));
25462
+ return [...full ? [`\u2026 earlier history: pass before: ${events[0].createdAt}`] : [], ...lines].join("\n");
24810
25463
  }
24811
25464
  },
24812
25465
  {
@@ -24887,66 +25540,83 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
24887
25540
  }
24888
25541
  function readJson(path3) {
24889
25542
  try {
24890
- return JSON.parse(readFileSync10(path3, "utf8"));
25543
+ return JSON.parse(readFileSync13(path3, "utf8"));
24891
25544
  } catch {
24892
25545
  return {};
24893
25546
  }
24894
25547
  }
24895
- function installClaude() {
25548
+ function installClaude(dir2) {
24896
25549
  const done = [];
25550
+ const primary = dir2 === join12(homedir4(), ".claude");
25551
+ const env = { ...process.env };
25552
+ if (primary) delete env.CLAUDE_CONFIG_DIR;
25553
+ else env.CLAUDE_CONFIG_DIR = dir2;
25554
+ const where = primary ? "" : ` for ${dir2}`;
24897
25555
  try {
24898
- execFileSync3("claude", ["mcp", "add", "--scope", "user", "slop", "--", "slop", "mcp"], { stdio: "ignore" });
24899
- done.push('Claude Code: MCP server "slop" registered (user scope)');
24900
- } catch {
24901
- done.push("Claude Code: run `claude mcp add --scope user slop -- slop mcp` (the claude command was not found here)");
25556
+ execFileSync5("claude", ["mcp", "add", "--scope", "user", "slop", "--", "slop", "mcp"], { stdio: ["ignore", "ignore", "pipe"], env });
25557
+ done.push(`Claude Code: MCP server "slop" registered (user scope${where})`);
25558
+ } catch (error62) {
25559
+ const said = String(error62.stderr ?? "") + String(error62.stdout ?? "");
25560
+ 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"})`);
24902
25562
  }
24903
- const dir = join7(homedir3(), ".claude");
24904
- const path3 = join7(dir, "settings.json");
24905
- mkdirSync6(dir, { recursive: true });
25563
+ const path3 = join12(dir2, "settings.json");
25564
+ mkdirSync8(dir2, { recursive: true });
24906
25565
  const settings = readJson(path3);
24907
- settings.hooks = mergeHooks(settings.hooks ?? {});
24908
- settings.env = { ...settings.env ?? {}, MCP_TOOL_TIMEOUT: "1800000" };
24909
- writeFileSync7(path3, `${JSON.stringify(settings, null, 2)}
25566
+ settings.hooks = mergeHooks(settings.hooks ?? {}, "claude");
25567
+ settings.env = { ...settings.env ?? {}, MCP_TOOL_TIMEOUT: "21600000" };
25568
+ writeFileSync8(path3, `${JSON.stringify(settings, null, 2)}
24910
25569
  `);
24911
- done.push(`Claude Code: PostToolUse and Stop hooks and MCP_TOOL_TIMEOUT written to ${path3}`);
25570
+ done.push(`Claude Code: SessionStart hook and MCP_TOOL_TIMEOUT written to ${path3}; other slop hooks removed (its session socket wakes it)`);
24912
25571
  return done;
24913
25572
  }
24914
- function mergeHooks(hooks) {
24915
- const stop = hooks.Stop ?? [];
24916
- if (!stop.some((entry) => entry.hooks?.some((hook) => hook.command === "slop hook stop"))) {
24917
- stop.push({ hooks: [{ type: "command", command: "slop hook stop", timeout: 30, statusMessage: "Checking Slopware Tasks for messages" }] });
25573
+ function mergeHooks(hooks, harness) {
25574
+ const ours = (hook) => typeof hook.command === "string" && /^slop hook /.test(hook.command);
25575
+ for (const event of Object.keys(hooks)) {
25576
+ const entries = hooks[event] ?? [];
25577
+ for (const entry of entries) entry.hooks = (entry.hooks ?? []).filter((hook) => !ours(hook));
25578
+ hooks[event] = entries.filter((entry) => (entry.hooks ?? []).length > 0);
25579
+ if (hooks[event].length === 0) delete hooks[event];
24918
25580
  }
24919
- const tick = hooks.PostToolUse ?? [];
24920
- if (!tick.some((entry) => entry.hooks?.some((hook) => hook.command === "slop hook tick"))) {
24921
- tick.push({ hooks: [{ type: "command", command: "slop hook tick", timeout: 5, statusMessage: "Slopware Tasks inbox" }] });
25581
+ const add = (event, verb, timeout, statusMessage) => {
25582
+ const entries = hooks[event] ?? [];
25583
+ entries.push({ hooks: [{ type: "command", command: `slop hook ${verb} --harness ${harness}`, timeout, statusMessage }] });
25584
+ hooks[event] = entries;
25585
+ };
25586
+ 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");
24922
25590
  }
24923
- return { ...hooks, Stop: stop, PostToolUse: tick };
25591
+ return hooks;
24924
25592
  }
24925
- function installCodex() {
25593
+ function installCodex(dir2) {
24926
25594
  const done = [];
24927
- const dir = join7(homedir3(), ".codex");
24928
- mkdirSync6(dir, { recursive: true });
24929
- const configPath2 = join7(dir, "config.toml");
24930
- const current = existsSync7(configPath2) ? readFileSync10(configPath2, "utf8") : "";
24931
- if (/^\[mcp_servers\.slop\]/m.test(current)) done.push(`Codex: MCP server already in ${configPath2}`);
24932
- else {
24933
- writeFileSync7(configPath2, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
25595
+ mkdirSync8(dir2, { recursive: true });
25596
+ const configPath3 = join12(dir2, "config.toml");
25597
+ const current = existsSync11(configPath3) ? readFileSync13(configPath3, "utf8") : "";
25598
+ 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"));
25600
+ done.push(`Codex: MCP server already in ${configPath3}`);
25601
+ } else {
25602
+ writeFileSync8(configPath3, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
24934
25603
  command = "slop"
24935
25604
  args = ["mcp"]
24936
- tool_timeout_sec = 1800
25605
+ tool_timeout_sec = 21600
24937
25606
  `);
24938
- done.push(`Codex: MCP server "slop" added to ${configPath2}`);
25607
+ done.push(`Codex: MCP server "slop" added to ${configPath3}`);
24939
25608
  }
24940
- const hooksPath = join7(dir, "hooks.json");
24941
- const file2 = readJson(hooksPath);
24942
- file2.hooks = mergeHooks(file2.hooks ?? {});
24943
- writeFileSync7(hooksPath, `${JSON.stringify(file2, null, 2)}
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)}
24944
25613
  `);
24945
- done.push(`Codex: PostToolUse and Stop hooks written to ${hooksPath}`);
25614
+ done.push(`Codex: SessionStart, UserPromptSubmit and Stop hooks written to ${hooksPath}`);
24946
25615
  return done;
24947
25616
  }
24948
- async function trustCodexHooks() {
24949
- const server = spawn2("codex", ["app-server"], { stdio: ["pipe", "pipe", "ignore"] });
25617
+ 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}`;
24950
25620
  let buffer = "";
24951
25621
  const pending = /* @__PURE__ */ new Map();
24952
25622
  server.stdout.on("data", (chunk) => {
@@ -24964,7 +25634,7 @@ async function trustCodexHooks() {
24964
25634
  }
24965
25635
  });
24966
25636
  let nextId = 1;
24967
- const call = (method, params) => new Promise((resolve2, reject) => {
25637
+ const call = (method, params) => new Promise((resolve5, reject) => {
24968
25638
  const id = nextId++;
24969
25639
  const timer = setTimeout(() => {
24970
25640
  pending.delete(id);
@@ -24973,7 +25643,7 @@ async function trustCodexHooks() {
24973
25643
  pending.set(id, (message) => {
24974
25644
  clearTimeout(timer);
24975
25645
  if (message.error) reject(new Error(message.error.message ?? `${method} failed`));
24976
- else resolve2(message.result);
25646
+ else resolve5(message.result);
24977
25647
  });
24978
25648
  server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
24979
25649
  `);
@@ -24981,48 +25651,64 @@ async function trustCodexHooks() {
24981
25651
  const notify = (method, params) => server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}
24982
25652
  `);
24983
25653
  try {
24984
- await call("initialize", { clientInfo: { name: "slop", version: "0.3.0" }, capabilities: { experimentalApi: true } });
25654
+ await call("initialize", { clientInfo: { name: "slop", version: "0.4.1" }, capabilities: { experimentalApi: true } });
24985
25655
  notify("initialized", {});
24986
25656
  const listed = await call("hooks/list", { cwds: [process.cwd()] });
24987
- const ours = listed.data.flatMap((entry) => entry.hooks).filter((hook) => hook.command === "slop hook tick" || hook.command === "slop hook stop");
24988
- if (ours.length === 0) return "Codex: could not find the slop hooks in its list; trust them once with /hooks in a Codex session";
25657
+ const ours = listed.data.flatMap((entry) => entry.hooks).filter((hook) => hook.command?.startsWith("slop hook "));
25658
+ if (ours.length === 0) return `Codex: could not find the slop hooks in its list${where}; trust them once with /hooks in a Codex session`;
24989
25659
  const untrusted = ours.filter((hook) => hook.trustStatus !== "trusted");
24990
- if (untrusted.length === 0) return "Codex: the slop hooks are already trusted";
25660
+ if (untrusted.length === 0) return `Codex: the slop hooks are already trusted${where}`;
24991
25661
  const value = Object.fromEntries(untrusted.map((hook) => [hook.key, { trusted_hash: hook.currentHash }]));
24992
25662
  await call("config/batchWrite", { edits: [{ keyPath: "hooks.state", value, mergeStrategy: "upsert" }], reloadUserConfig: true });
24993
- return `Codex: trusted ${untrusted.length} slop hook(s), so it will not ask`;
25663
+ return `Codex: trusted ${untrusted.length} slop hook(s)${where}, so it will not ask`;
24994
25664
  } catch (error62) {
24995
- return `Codex: could not trust the hooks automatically (${error62 instanceof Error ? error62.message : String(error62)}); trust them once with /hooks in a Codex session`;
25665
+ return `Codex: could not trust the hooks automatically${where} (${error62 instanceof Error ? error62.message : String(error62)}); trust them once with /hooks in a Codex session`;
24996
25666
  } finally {
24997
25667
  server.kill();
24998
25668
  }
24999
25669
  }
25000
- async function hookCwd() {
25001
- const input2 = await new Promise((resolve2) => {
25670
+ async function healCodexTrust() {
25671
+ const home = ownHarnessHome("CODEX_HOME");
25672
+ if (!home) return;
25673
+ const hooksPath = join12(home, "hooks.json");
25674
+ const configPath3 = join12(home, "config.toml");
25675
+ if (!existsSync11(hooksPath) || !existsSync11(configPath3)) return;
25676
+ if (!readFileSync13(hooksPath, "utf8").includes("slop hook stop")) return;
25677
+ if (readFileSync13(configPath3, "utf8").includes(`hooks.state."${hooksPath}:stop:`)) return;
25678
+ await trustCodexHooks(home);
25679
+ }
25680
+ async function hookInput(harness) {
25681
+ const input2 = await new Promise((resolve5) => {
25002
25682
  let data = "";
25003
25683
  process.stdin.on("data", (chunk) => data += chunk);
25004
- process.stdin.on("end", () => resolve2(data));
25005
- setTimeout(() => resolve2(data), 2e3);
25684
+ process.stdin.on("end", () => resolve5(data));
25685
+ setTimeout(() => resolve5(data), 2e3);
25006
25686
  });
25687
+ let cwd = process.cwd();
25688
+ let conversation = null;
25007
25689
  try {
25008
- return JSON.parse(input2).cwd ?? process.cwd();
25690
+ const parsed = JSON.parse(input2);
25691
+ cwd = parsed.cwd ?? cwd;
25692
+ conversation = parsed.session_id ?? null;
25009
25693
  } catch {
25010
- return process.cwd();
25011
25694
  }
25695
+ if (conversation && !nonInteractiveHarness()) rememberConversation(cwd, { id: conversation, harness: harness ?? "other" });
25696
+ return { cwd, conversation };
25012
25697
  }
25013
- async function tickHook() {
25014
- const arrived = inboxDrain(await hookCwd());
25015
- if (arrived.length === 0) return;
25016
- const text = `Messages arrived on Slopware Tasks while you worked:
25017
-
25018
- ${arrived.map(pushBlock).join("\n\n")}
25019
-
25020
- Act or answer with the slop MCP \`reply\` tool, then carry on.`;
25021
- process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } })}
25698
+ async function promptHook(harness) {
25699
+ const { cwd } = await hookInput(harness);
25700
+ markWake(cwd);
25701
+ }
25702
+ async function startHook(harness) {
25703
+ const { conversation } = await hookInput(harness);
25704
+ if (!conversation) return;
25705
+ const text = `On Slopware Tasks you are ${agentNameFor(conversation)}: that is how people and other agents address you (session who lists everyone).`;
25706
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text } })}
25022
25707
  `);
25023
25708
  }
25024
- async function stopHook() {
25025
- const cwd = await hookCwd();
25709
+ async function stopHook(harness) {
25710
+ if (nonInteractiveHarness()) return;
25711
+ const { cwd } = await hookInput(harness);
25026
25712
  process.chdir(cwd);
25027
25713
  const parts = inboxDrain(cwd).map(pushBlock);
25028
25714
  const repo = findRepo();
@@ -25044,12 +25730,40 @@ ${parts.join("\n\n")}` })}
25044
25730
  }
25045
25731
  const general = await api.get(`/projects/${project.id}/threads?scope=general&unread=1&read=1`);
25046
25732
  if (general.length) parts.push("Unread general threads (context):", ...general.map(threadLine));
25047
- if (parts.length === 0) return;
25048
- process.stdout.write(`${JSON.stringify({ decision: "block", reason: `Messages arrived on Slopware Tasks while you worked. Read them (slop thread show <id>), act or reply (slop thread reply <id> "\u2026"), then continue or stop.
25733
+ if (parts.length) {
25734
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: `Messages arrived on Slopware Tasks while you worked. Read them (threads read), act or reply (say reply), then continue or stop.
25049
25735
 
25050
25736
  ${parts.join("\n")}` })}
25737
+ `);
25738
+ return;
25739
+ }
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." })}
25051
25764
  `);
25052
25765
  }
25766
+ var STANDBY_HOLD_MS = 5 * 60 * 60 * 1e3;
25053
25767
  function tools() {
25054
25768
  const fine = handlers();
25055
25769
  const run2 = (name, args) => {
@@ -25072,13 +25786,13 @@ function tools() {
25072
25786
  "board",
25073
25787
  "The project: tasks on the board, columns, members, projects.",
25074
25788
  { list: "list_tasks", columns: "columns", members: "members", projects: "projects" },
25075
- { status: str("list: only this column"), mine: { type: "boolean" }, all: { type: "boolean", description: "list: include done" }, archived: { type: "boolean" } }
25789
+ { status: str("list: only this column"), mine: { type: "boolean" }, all: { type: "boolean", description: "list: include done" }, archived: { type: "boolean" }, limit: int2("list: rows (default 50)"), offset: int2("list: skip") }
25076
25790
  ),
25077
25791
  group(
25078
25792
  "task",
25079
25793
  "One task. show; create; update (only given fields change); move; link; archive/restore; history; finish (first done column, releases your session).",
25080
25794
  { show: "show_task", create: "create_task", update: "update_task", move: "move_task", link: "link_task", archive: "archive_task", restore: "archive_task", history: "task_history", finish: "finish_task" },
25081
- { task: str("Key, e.g. WEB-12 (all but create)"), title: str(""), description: str("Markdown"), notes: str("scratchpad, Markdown"), severity: str("low|medium|high|critical"), column: str("column key or label"), assignee: str("email, name, me, or none"), url: str("link: the URL") }
25795
+ { task: str("Key, e.g. WEB-12 (all but create)"), title: str(""), description: str("Markdown"), notes: str("scratchpad, Markdown"), severity: str("low|medium|high|critical"), column: str("column key or label"), assignee: str("email, name, me, or none"), url: str("link: the URL"), limit: int2("history: newest N (default 50)"), before: str("history: page back from this time") }
25082
25796
  ),
25083
25797
  group(
25084
25798
  "plan",
@@ -25088,16 +25802,16 @@ function tools() {
25088
25802
  ),
25089
25803
  group(
25090
25804
  "session",
25091
- "Being live. claim: take a task, go live on it, get what is unread (messages then reach you after any tool call). release; finish is on task. who: live sessions. check: unread now. wait: block for the next message (optional).",
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).",
25092
25806
  { claim: "claim_task", release: "release_task", who: "sessions", check: "check_messages", wait: "wait_for_message" },
25093
- { task: str("Task key"), minutes: int2("wait: give up after (default 10)") }
25807
+ { task: str("Task key"), minutes: int2("wait: give up after (default 300)") }
25094
25808
  ),
25095
- group("threads", "Talk between agents and people. list threads; read one (marks it read).", { list: "threads", read: "thread" }, { task: str("list: about this task"), unread: { type: "boolean" }, id: str("read: thread id") }),
25809
+ 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") }),
25096
25810
  group(
25097
25811
  "say",
25098
- "post: start a thread (with task: reaches whoever is live on it; without: general, for people). reply: in a thread; reaches its task's live sessions and its participants.",
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.",
25099
25813
  { post: "post", reply: "reply" },
25100
- { subject: str("post"), body: str("Markdown"), task: str("post: about this task"), thread: str("reply: thread id") }
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") }
25101
25815
  )
25102
25816
  ];
25103
25817
  }
@@ -25105,7 +25819,7 @@ function withInbox(list, version2) {
25105
25819
  return list.map((tool) => ({
25106
25820
  ...tool,
25107
25821
  run: async (args) => {
25108
- const result = await tool.run(args);
25822
+ const result = bounded(await tool.run(args));
25109
25823
  const parts = [result];
25110
25824
  if (!(tool.name === "session" && (args.action === "check" || args.action === "wait"))) {
25111
25825
  const arrived = inboxDrain(process.cwd());
@@ -25122,25 +25836,28 @@ function registerMcpCommands(program3, version2) {
25122
25836
  const mcp = program3.command("mcp").description("The MCP server: the board and its threads as tools for Claude Code, Codex and any MCP client. Holds your live session while it runs.");
25123
25837
  mcp.command("serve", { isDefault: true }).description("Serve MCP over stdio (what the harness runs).").action(() => serveMcp(withInbox(tools(), version2), { name: "slop", version: version2 }));
25124
25838
  mcp.command("install").description("Register the server and the two hooks with Claude Code and Codex on this machine.").action(async () => {
25125
- const lines = [...installClaude(), ...installCodex()];
25839
+ const homes = codexHomes();
25840
+ const lines = [...claudeHomes().flatMap((home) => installClaude(home)), ...homes.flatMap((home) => installCodex(home))];
25126
25841
  let codexPresent = true;
25127
25842
  try {
25128
- execFileSync3("codex", ["--version"], { stdio: "ignore" });
25843
+ execFileSync5("codex", ["--version"], { stdio: "ignore" });
25129
25844
  } catch {
25130
25845
  codexPresent = false;
25131
25846
  }
25132
- if (codexPresent) lines.push(await trustCodexHooks());
25847
+ if (codexPresent) for (const home of homes) lines.push(await trustCodexHooks(home));
25133
25848
  emit({ done: lines }, () => lines.join("\n"));
25134
25849
  });
25135
25850
  const hook = program3.command("hook").description("Harness hooks (installed by slop mcp install).");
25136
- hook.command("stop").description("Stop hook (Claude Code and Codex): hands over unread messages instead of letting the agent stop.").action(stopHook);
25137
- hook.command("tick").description("PostToolUse hook (Claude Code and Codex): hands over messages the held session received, as context, right after any tool call.").action(tickHook);
25851
+ const harnessOf2 = (options) => options.harness === "claude" || options.harness === "codex" ? options.harness : options.harness ? "other" : null;
25852
+ hook.command("start").description("SessionStart hook (Claude Code and Codex): writes the conversation down so the MCP server is its session, and tells the agent its name.").option("--harness <name>", "claude or codex").action((options) => startHook(harnessOf2(options)));
25853
+ hook.command("prompt").description("UserPromptSubmit hook (Codex): the person typed; a waiting agent returns to handle it.").option("--harness <name>", "claude or codex").action((options) => promptHook(harnessOf2(options)));
25854
+ hook.command("stop").description("Stop hook (Codex): the wait. Holds the turn open at no cost until a message arrives, the person types, or the agent is made unreachable.").option("--harness <name>", "claude or codex").action((options) => stopHook(harnessOf2(options)));
25138
25855
  }
25139
25856
 
25140
25857
  // src/commands/sessions.ts
25141
- import { readFileSync as readFileSync11 } from "fs";
25858
+ import { readFileSync as readFileSync14 } from "fs";
25142
25859
  var taskPath2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
25143
- var sessionLine = (session) => [session.taskKey ?? "(no task)", session.user.name, session.machine.name, session.startedAt];
25860
+ var sessionLine2 = (session) => [session.agent?.name ?? "agent", session.taskKey ?? "(no task)", session.user.name, session.machine.name, session.where?.directory ? `${session.where.directory}${session.where.branch ? `@${session.where.branch}` : ""}` : "-", session.startedAt, session.state === "reconnecting" ? "reconnecting" : "live"];
25144
25861
  function registerSessionCommands(program3, task) {
25145
25862
  const withProject = (command) => command.option("-p, --project <key>", "project key (default: the project this repository is linked to)");
25146
25863
  const session = program3.command("session").description("Claim a task and be reachable on it while you work.");
@@ -25171,7 +25888,8 @@ function registerSessionCommands(program3, task) {
25171
25888
  `);
25172
25889
  const outcome = await waitForMessage(api, project.id, key, {
25173
25890
  timeoutMs: options.timeout ? Number(options.timeout) * 1e3 : 0,
25174
- onHello: (session2) => note2(isJsonMode() ? JSON.stringify({ live: session2 }) : `live on ${session2.taskKey} as ${session2.user.name} (${session2.machine.name}); waiting\u2026`),
25891
+ claim: agentIdentity(process.cwd(), "directory"),
25892
+ onHello: (session2) => note2(isJsonMode() ? JSON.stringify({ live: session2 }) : `live on ${session2.taskKey} as ${session2.agent?.name ?? "agent"} for ${session2.user.name} (${session2.machine.name}); waiting\u2026`),
25175
25893
  onMessage: options.follow ? (push) => {
25176
25894
  emit(push, () => `${pushBlock(push)}
25177
25895
  `);
@@ -25196,7 +25914,7 @@ function registerSessionCommands(program3, task) {
25196
25914
  const api = authedApi();
25197
25915
  const project = await resolveProject(api, options.project);
25198
25916
  const sessions = await api.get(`/projects/${project.id}/sessions`);
25199
- emit(sessions, () => sessions.length === 0 ? "Nobody is live." : table([["TASK", "WHO", "MACHINE", "SINCE"], ...sessions.map(sessionLine)]));
25917
+ emit(sessions, () => sessions.length === 0 ? "Nobody is live." : table([["AGENT", "TASK", "WHO", "MACHINE", "WHERE", "SINCE", "STATE"], ...sessions.map(sessionLine2)]));
25200
25918
  });
25201
25919
  }
25202
25920
 
@@ -25222,13 +25940,13 @@ ${written.map((path3) => ` ${path3}`).join("\n")}`);
25222
25940
  }
25223
25941
 
25224
25942
  // src/index.ts
25225
- 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.3.0").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => setJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
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)");
25226
25944
  registerAuthCommands(program2);
25227
25945
  registerProjectCommands(program2);
25228
25946
  var taskCommand = registerTaskCommands(program2);
25229
25947
  registerSessionCommands(program2, taskCommand);
25230
25948
  registerThreadCommands(program2, taskCommand);
25231
- registerMcpCommands(program2, "0.3.0");
25949
+ registerMcpCommands(program2, "0.4.1");
25232
25950
  registerGuideCommand(program2);
25233
25951
  registerSkillCommands(program2);
25234
25952
  program2.parseAsync().catch(fail);