@pitboss/cli 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,142 +1,78 @@
1
1
  # @pitboss/cli
2
2
 
3
- Connect your coding agents to a pitboss instance.
3
+ Connect your coding agents to Pitboss so they can share tasks, review each other's work and hand off across repos.
4
4
 
5
5
  ```bash
6
- npx @pitboss/cli join --api https://api.your-instance.example
6
+ npx @pitboss/cli join
7
7
  ```
8
8
 
9
- It detects the repo and which agent is running it, shows you a short code, and
10
- waits. You approve in the browser and say what that agent does. It then writes
11
- that agent's MCP config and checks the new token authenticates.
9
+ Run it from the agent's working folder (or tell the agent to run it). It prints a short code and a URL. Open the URL, sign in, and type the code yourself: an agent cannot approve its own join. Codes expire after ten minutes.
12
10
 
13
- Only the agent that ran the command is connected. To add another, run `join`
14
- again from its own worktree, on this machine or any other, and pick the same
15
- repo when you approve.
11
+ On the approval screen you pick:
16
12
 
17
- This package also ships the MCP server, run as `pitboss mcp`, so an onboarded
18
- agent can never run a build older than the CLI that configured it.
13
+ - **Repo.** Proposed from the git remote, or the folder name if there is none. Pick an existing repo to add this agent to it.
14
+ - **Role.** `orchestrator` plans and assigns work in a repo. `worker` picks up tasks.
15
+ - **Top-level agent.** Tick "Make this your top-level agent" for one agent that acts for you across every repo: it can list and create repos, invite agents, and create tasks in any repo. Leave it unticked for everyone else.
19
16
 
20
- ---
17
+ `join` then writes the agent's config and checks the new token works:
21
18
 
22
- `relai` — operator CLI for [relai](https://github.com/phillipsio/relai). Register agents, manage tasks, send messages, and coordinate work between humans and AI agents from the terminal.
19
+ | Runtime | Files written |
20
+ |---|---|
21
+ | Claude Code | `.mcp.json` (server `pitboss`), `.claude/settings.local.json` (approves the server and installs the wake-up hooks) |
22
+ | Cursor | `.cursor/mcp.json` (server `pitboss`), `.cursor/hooks.json` (stop hook), `.cursor/rules/pitboss.mdc` (wake-up rule) |
23
23
 
24
- ## Install
24
+ `.mcp.json` and `.cursor/mcp.json` hold a secret token. `join` adds every file it writes to `.git/info/exclude`, and refuses to write into a file git already tracks.
25
25
 
26
- ```bash
27
- npm install -g @getrelai/cli
28
- ```
26
+ **Restart the agent session after joining.** An agent loads its tools at startup.
29
27
 
30
- ## Quick start
28
+ Each `join` connects one agent. To add another, run `join` again from that agent's own folder or worktree and pick the same repo.
31
29
 
32
- You need a relai API to talk to. If a teammate has one running and sends you an invite code:
33
-
34
- ```bash
35
- relai login --invite inv_<code> --api <https://your-relai-host>
36
- ```
30
+ ## Wake-ups
37
31
 
38
- If you're standing one up yourself, see the [main repo](https://github.com/phillipsio/relai) for setup; then:
32
+ Pitboss does not push into a running agent. Each agent runs a watcher in the background that exits when a task, message or review arrives for it; the agent then wakes, handles it, and starts the watcher again. The hooks `join` installs do this for you. Without them an agent only notices new work when you next prompt it.
39
33
 
40
34
  ```bash
41
- relai init # guided setup — saves config to ~/.config/pitboss/config.json
35
+ pitboss watch --once # block until the next event for this folder's agent, print it as JSON, exit 0
36
+ pitboss watch # stream events until Ctrl-C (for a human watching a terminal)
42
37
  ```
43
38
 
44
- After either path, `relai init` / `relai login` prints a ready-to-paste `mcpServers` block — drop it into your project's `.mcp.json` (or `~/.claude.json`) so your AI agent (Claude Code, Cursor, Copilot, etc.) can use the relai tools directly.
39
+ `watch --once` reads the agent from the folder's `.mcp.json` or `.cursor/mcp.json` (`--dir` picks another folder), reconnects on network errors, and resumes where the last run stopped, so an event that arrives between two runs is not lost. If the token is revoked or the agent deleted, it prints a line starting `PITBOSS-CONFIG-REFUSED` and exits 1: relaunching will not help.
45
40
 
46
41
  ## Commands
47
42
 
48
- **Identity / setup**
49
-
50
43
  ```bash
51
- relai init Register this machine as an agent
52
- relai login --invite <code> Accept a project invite (no admin secret needed)
53
- relai status Your agent info, online agents, task summary, unread count
54
- relai whoami — see `relai status`
44
+ pitboss join [--api <url>] Connect this agent (default: the hosted instance)
45
+ pitboss status You, who is online, task counts, unread messages
46
+ pitboss start Session orientation: context, open tasks, unread, open threads
47
+ pitboss watch [--once] [--dir <path>] Live events for this agent
48
+ pitboss tasks [--all] Your tasks, or every task in the repo
49
+ pitboss task create -t <title> -d <desc> --to <agent> [--review-by <agent>]
50
+ pitboss task start|done|block|cancel <id> [-n "<note>"]
51
+ pitboss task review <id> -d approve|reject -n "<note>"
52
+ pitboss threads | thread new <title> | send <threadId> -m "<msg>"
53
+ pitboss inbox [-r] Unread messages and reviews waiting on you (-r marks them read)
54
+ pitboss repos | agents List repos / agents in this repo
55
+ pitboss repo invite --name <n> --role worker Invite code for an agent on another machine
56
+ pitboss login --invite <code> --api <url> --worker-type <type> Redeem an invite
57
+ pitboss token list | rotate | revoke <id>
55
58
  ```
56
59
 
57
- **Discovery**
60
+ `--review-by` makes a task wait for that agent's approval before it counts as done. A reject sends it back to the assignee with the note.
58
61
 
59
- ```bash
60
- relai projects List projects on the server
61
- relai project show [id] Show project details (defaults to current)
62
- relai agents List agents in the current project
63
- ```
62
+ Pass `--no-input` (or pipe stdin) to fail fast instead of prompting.
64
63
 
65
- **Tasks**
64
+ ## Several identities on one machine
66
65
 
67
- ```bash
68
- relai tasks Your assigned + in_progress tasks
69
- relai tasks --all All tasks in the project
70
- relai tasks --status pending Filter by status (comma-separated ok)
71
-
72
- relai task create Interactive: title, description, priority, assignee
73
- relai task create --to <agent|@auto> --domains web,api
74
- relai task create --review-by <agent> Shorthand for reviewer-agent gating
75
- relai task create --verify <cmd> Shell-predicate gating (orchestrator only)
76
- relai task start <id> Mark in_progress
77
- relai task done <id> Mark completed
78
- relai task block <id> -n "reason" Mark blocked with a note
79
- relai task cancel <id> Mark cancelled
80
- relai task review <id> --decision approve|reject [-n "..."]
81
- Submit a decision on a reviewer-agent
82
- -gated task (you must be the named reviewer)
83
- ```
84
-
85
- **Threads & messages**
66
+ Set `PITBOSS_CONFIG_DIR` per agent:
86
67
 
87
68
  ```bash
88
- relai threads List all threads
89
- relai thread new "Phase 3" Create a thread
90
-
91
- relai send <threadId> Interactive: prompts for type + body
92
- relai send <threadId> -m "..." -t handoff --to <agent>
93
- relai inbox Unread messages, plus any tasks awaiting your review
94
- relai inbox --read Show and mark all as read
69
+ PITBOSS_CONFIG_DIR=~/.config/pitboss/cursor-web pitboss login --invite <code> --api https://api.pitboss.dev --worker-type cursor
95
70
  ```
96
71
 
97
- **Non-interactive use.** Pass `--no-input` (or set `RELAI_NO_INPUT=1`, or pipe stdin) and missing flags fail fast with exit code 2 instead of opening a prompt — making the CLI scriptable from CI.
72
+ Config lives in `~/.config/pitboss/config.json` (or `$PITBOSS_CONFIG_DIR/config.json`).
98
73
 
99
- **Project ops**
100
-
101
- ```bash
102
- relai project context show Print the project's pinned context
103
- relai project context edit Edit pinned context in $EDITOR
104
- relai project invite Issue a one-time invite code for `relai login`
105
- ```
74
+ ## MCP server
106
75
 
107
- **Tokens**
108
-
109
- ```bash
110
- relai token rotate Issue a new per-agent token, save to config
111
- relai token revoke <tokenId> Revoke a specific token
112
- ```
113
-
114
- ## Message types
115
-
116
- `status` · `handoff` · `finding` · `decision` · `question` · `escalation` · `reply`
117
-
118
- ## Multi-identity
119
-
120
- Set `PITBOSS_CONFIG_DIR` to override the config location and run multiple agent identities side by side from one machine:
121
-
122
- ```bash
123
- export PITBOSS_CONFIG_DIR=/tmp/relai-coworker
124
- relai login --invite inv_<...>
125
- ```
126
-
127
- ## Config
128
-
129
- `~/.config/pitboss/config.json` (or `$PITBOSS_CONFIG_DIR/config.json`):
130
-
131
- ```json
132
- {
133
- "apiUrl": "...",
134
- "apiToken": "...",
135
- "agentId": "agent_...",
136
- "agentName": "...",
137
- "projectId": "proj_...",
138
- "specialization": "..."
139
- }
140
- ```
76
+ The same package runs the MCP server as `pitboss mcp`, so an agent never runs an older server than the CLI that set it up.
141
77
 
142
- The `apiToken` is your per-agent bearer credential. `relai init` and `relai login` write it; `relai token rotate` issues a fresh one.
78
+ Getting started guide: https://pitboss.dev/docs/getting-started
package/dist/cli.js CHANGED
@@ -2161,9 +2161,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
2161
2161
  _chainOrCallSubCommandHook(promise, subCommand, event) {
2162
2162
  let result = promise;
2163
2163
  if (this._lifeCycleHooks[event] !== void 0) {
2164
- this._lifeCycleHooks[event].forEach((hook) => {
2164
+ this._lifeCycleHooks[event].forEach((hook2) => {
2165
2165
  result = this._chainOrCall(result, () => {
2166
- return hook(this, subCommand);
2166
+ return hook2(this, subCommand);
2167
2167
  });
2168
2168
  });
2169
2169
  }
@@ -10864,7 +10864,7 @@ var require_lib2 = __commonJS({
10864
10864
  return matches;
10865
10865
  };
10866
10866
  exports.analyse = analyse;
10867
- var detectFile = (filepath, opts = {}) => new Promise((resolve2, reject) => {
10867
+ var detectFile = (filepath, opts = {}) => new Promise((resolve4, reject) => {
10868
10868
  let fd;
10869
10869
  const fs = (0, node_1.default)();
10870
10870
  const handler = (err, buffer) => {
@@ -10874,7 +10874,7 @@ var require_lib2 = __commonJS({
10874
10874
  if (err) {
10875
10875
  reject(err);
10876
10876
  } else if (buffer) {
10877
- resolve2((0, exports.detect)(buffer));
10877
+ resolve4((0, exports.detect)(buffer));
10878
10878
  } else {
10879
10879
  reject(new Error("No error and no buffer received"));
10880
10880
  }
@@ -14704,10 +14704,6 @@ var require_lib3 = __commonJS({
14704
14704
  }
14705
14705
  });
14706
14706
 
14707
- // src/index.ts
14708
- import { readFileSync as readFileSync6 } from "node:fs";
14709
- import { dirname as dirname4, join as join5 } from "node:path";
14710
-
14711
14707
  // ../../node_modules/.pnpm/commander@12.1.0/node_modules/commander/esm.mjs
14712
14708
  var import_index = __toESM(require_commander(), 1);
14713
14709
  var {
@@ -17285,13 +17281,13 @@ var PromisePolyfill = class extends Promise {
17285
17281
  // Available starting from Node 22
17286
17282
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
17287
17283
  static withResolver() {
17288
- let resolve2;
17284
+ let resolve4;
17289
17285
  let reject;
17290
17286
  const promise = new Promise((res, rej) => {
17291
- resolve2 = res;
17287
+ resolve4 = res;
17292
17288
  reject = rej;
17293
17289
  });
17294
- return { promise, resolve: resolve2, reject };
17290
+ return { promise, resolve: resolve4, reject };
17295
17291
  }
17296
17292
  };
17297
17293
 
@@ -17328,7 +17324,7 @@ function createPrompt(view) {
17328
17324
  });
17329
17325
  output.mute();
17330
17326
  const screen = new ScreenManager(rl);
17331
- const { promise, resolve: resolve2, reject } = PromisePolyfill.withResolver();
17327
+ const { promise, resolve: resolve4, reject } = PromisePolyfill.withResolver();
17332
17328
  const cancel = () => reject(new CancelPromptError());
17333
17329
  if (signal) {
17334
17330
  const abort = () => reject(new AbortPromptError({ cause: signal.reason }));
@@ -17359,7 +17355,7 @@ function createPrompt(view) {
17359
17355
  try {
17360
17356
  const nextView = view(config, (value) => {
17361
17357
  if (effectsSettled) {
17362
- resolve2(value);
17358
+ resolve4(value);
17363
17359
  } else {
17364
17360
  pendingDone = { value };
17365
17361
  }
@@ -17382,7 +17378,7 @@ function createPrompt(view) {
17382
17378
  if (pendingDone !== null) {
17383
17379
  const { value } = pendingDone;
17384
17380
  pendingDone = null;
17385
- resolve2(value);
17381
+ resolve4(value);
17386
17382
  }
17387
17383
  });
17388
17384
  };
@@ -17537,7 +17533,7 @@ var ExternalEditor = class {
17537
17533
  }
17538
17534
  runAsync(callback) {
17539
17535
  this.createTempFile();
17540
- const promise = new Promise((resolve2, reject) => {
17536
+ const promise = new Promise((resolve4, reject) => {
17541
17537
  try {
17542
17538
  const editorProcess = spawn(this.editor.bin, this.editorArgs(), {
17543
17539
  shell: false,
@@ -17548,7 +17544,7 @@ var ExternalEditor = class {
17548
17544
  });
17549
17545
  editorProcess.once("exit", (code) => {
17550
17546
  this.lastExitStatus = code ?? 0;
17551
- resolve2();
17547
+ resolve4();
17552
17548
  });
17553
17549
  } catch (launchError) {
17554
17550
  reject(new LaunchEditorError(launchError));
@@ -17623,7 +17619,7 @@ var editorTheme = {
17623
17619
  }
17624
17620
  };
17625
17621
  var dist_default4 = createPrompt((config, done) => {
17626
- const { waitForUserInput = true, file: { postfix = config.postfix ?? ".txt", ...fileProps } = {}, validate: validate2 = () => true } = config;
17622
+ const { waitForUserInput = true, file: { postfix = config.postfix ?? ".txt", ...fileProps } = {}, validate: validate3 = () => true } = config;
17627
17623
  const theme = makeTheme(editorTheme, config.theme);
17628
17624
  const [status, setStatus] = useState("idle");
17629
17625
  const [value = "", setValue] = useState(config.default);
@@ -17635,7 +17631,7 @@ var dist_default4 = createPrompt((config, done) => {
17635
17631
  const answer = await editAsync(value, { postfix, ...fileProps });
17636
17632
  rl.resume();
17637
17633
  setStatus("loading");
17638
- const isValid = await validate2(answer);
17634
+ const isValid = await validate3(answer);
17639
17635
  if (isValid === true) {
17640
17636
  setError(void 0);
17641
17637
  setStatus("done");
@@ -17762,7 +17758,7 @@ var dist_default6 = createPrompt((config, done) => {
17762
17758
  const [errorMsg, setError] = useState();
17763
17759
  const [value, setValue] = useState("");
17764
17760
  const prefix = usePrefix({ status, theme });
17765
- async function validate2(value2) {
17761
+ async function validate3(value2) {
17766
17762
  const { required, pattern, patternError = "Invalid input" } = config;
17767
17763
  if (required && !value2) {
17768
17764
  return "You must provide a value";
@@ -17782,7 +17778,7 @@ var dist_default6 = createPrompt((config, done) => {
17782
17778
  if (isEnterKey(key)) {
17783
17779
  const answer = value || defaultValue;
17784
17780
  setStatus("loading");
17785
- const isValid = await validate2(answer);
17781
+ const isValid = await validate3(answer);
17786
17782
  if (isValid === true) {
17787
17783
  setValue(answer);
17788
17784
  setStatus("done");
@@ -18908,29 +18904,189 @@ async function startCommand() {
18908
18904
  }
18909
18905
 
18910
18906
  // src/commands/token.ts
18911
- import { execFileSync } from "node:child_process";
18912
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
18907
+ import { execFileSync as execFileSync2 } from "node:child_process";
18908
+ import { existsSync as existsSync2, readFileSync as readFileSync4 } from "node:fs";
18913
18909
  import { homedir as homedir2 } from "node:os";
18910
+ import { join as join4 } from "node:path";
18911
+
18912
+ // src/lib/wake.ts
18913
+ import { execFileSync } from "node:child_process";
18914
+ import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, realpathSync, renameSync as renameSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
18915
+ import { createHash, randomBytes as randomBytes2 } from "node:crypto";
18916
+ import { join as join2, resolve } from "node:path";
18917
+ var EVENT_ID = /^evt_[A-Za-z0-9_-]{1,64}$/;
18918
+ function parseFrame(frame) {
18919
+ let id = null;
18920
+ let data = "";
18921
+ for (const line of frame.split("\n")) {
18922
+ if (line.startsWith("id:")) id = line.slice(3).trim();
18923
+ else if (line.startsWith("data:")) data += line.slice(5).trimStart();
18924
+ }
18925
+ if (!data) return { id, event: null };
18926
+ try {
18927
+ return { id, event: JSON.parse(data) };
18928
+ } catch {
18929
+ return { id, event: null };
18930
+ }
18931
+ }
18932
+ async function readWake(stream, { idleMs = 1e3, stallMs = 75e3 } = {}) {
18933
+ const reader = stream.getReader();
18934
+ const decoder = new TextDecoder();
18935
+ const wake = { first: null, lastId: null, count: 0, truncated: false };
18936
+ let buffer = "";
18937
+ try {
18938
+ for (; ; ) {
18939
+ const wait = wake.first ? idleMs : stallMs;
18940
+ let timer;
18941
+ const timeout = new Promise((r) => {
18942
+ timer = setTimeout(() => r("timeout"), wait);
18943
+ });
18944
+ const next = await Promise.race([reader.read(), timeout]);
18945
+ clearTimeout(timer);
18946
+ if (next === "timeout") {
18947
+ if (wake.first) return wake;
18948
+ throw new Error(`event stream stalled: no data for ${Math.round(stallMs / 1e3)}s`);
18949
+ }
18950
+ if (next.done) return wake;
18951
+ buffer += decoder.decode(next.value, { stream: true });
18952
+ let sep2;
18953
+ while ((sep2 = buffer.indexOf("\n\n")) !== -1) {
18954
+ const { id, event } = parseFrame(buffer.slice(0, sep2));
18955
+ buffer = buffer.slice(sep2 + 2);
18956
+ if (id && EVENT_ID.test(id)) wake.lastId = id;
18957
+ if (event?.kind === "replay.truncated" && !wake.first) {
18958
+ wake.truncated = true;
18959
+ return wake;
18960
+ }
18961
+ if (!event || event.kind === "replay.truncated") continue;
18962
+ wake.first ??= event;
18963
+ wake.count++;
18964
+ }
18965
+ }
18966
+ } finally {
18967
+ reader.cancel().catch(() => {
18968
+ });
18969
+ }
18970
+ }
18971
+ function wakeKey(agentId, folder) {
18972
+ let path3 = resolve(folder);
18973
+ try {
18974
+ path3 = realpathSync.native(path3);
18975
+ } catch {
18976
+ path3 = resolve(folder);
18977
+ }
18978
+ return `${agentId}.${createHash("sha256").update(path3).digest("hex").slice(0, 12)}`;
18979
+ }
18980
+ function readCursor(dir, key) {
18981
+ try {
18982
+ const raw = readFileSync3(join2(dir, `${key}.cursor`), "utf-8").trim();
18983
+ return EVENT_ID.test(raw) ? raw : null;
18984
+ } catch {
18985
+ return null;
18986
+ }
18987
+ }
18988
+ function writeAtomic(file, body) {
18989
+ const tmp = `${file}.${randomBytes2(6).toString("hex")}`;
18990
+ writeFileSync3(tmp, body, { mode: 384, flag: "wx" });
18991
+ renameSync2(tmp, file);
18992
+ }
18993
+ function writeCursor(dir, key, id) {
18994
+ if (!EVENT_ID.test(id)) return;
18995
+ mkdirSync2(dir, { recursive: true, mode: 448 });
18996
+ writeAtomic(join2(dir, `${key}.cursor`), `${id}
18997
+ `);
18998
+ }
18999
+ function followupCount(dir, key) {
19000
+ try {
19001
+ const n = Number(readFileSync3(join2(dir, `${key}.followups`), "utf-8").trim());
19002
+ return Number.isInteger(n) && n > 0 ? n : 0;
19003
+ } catch {
19004
+ return 0;
19005
+ }
19006
+ }
19007
+ function recordFollowup(dir, key) {
19008
+ mkdirSync2(dir, { recursive: true, mode: 448 });
19009
+ writeAtomic(join2(dir, `${key}.followups`), `${followupCount(dir, key) + 1}
19010
+ `);
19011
+ }
19012
+ function resetFollowups(dir, key) {
19013
+ rmSync3(join2(dir, `${key}.followups`), { force: true });
19014
+ }
19015
+ function resetAgentFollowups(dir, agentId) {
19016
+ let names;
19017
+ try {
19018
+ names = readdirSync(dir);
19019
+ } catch {
19020
+ return;
19021
+ }
19022
+ for (const name of names) {
19023
+ if (name.startsWith(`${agentId}.`) && /^agent_[A-Za-z0-9_-]+\.[0-9a-f]{12}\.followups$/.test(name)) rmSync3(join2(dir, name), { force: true });
19024
+ }
19025
+ }
19026
+ function isAlive(pid) {
19027
+ try {
19028
+ process.kill(pid, 0);
19029
+ return true;
19030
+ } catch (err) {
19031
+ return err.code === "EPERM";
19032
+ }
19033
+ }
19034
+ function isWatcherCommand(command) {
19035
+ return /(^|\s)watch --once(\s|$)/.test(command);
19036
+ }
19037
+ function psCommand(pid) {
19038
+ if (process.platform === "win32") return "watch --once";
19039
+ try {
19040
+ return execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
19041
+ } catch {
19042
+ return "";
19043
+ }
19044
+ }
19045
+ function watcherAlive(dir, key, { commandOf = psCommand } = {}) {
19046
+ let names;
19047
+ try {
19048
+ names = readdirSync(dir);
19049
+ } catch {
19050
+ return false;
19051
+ }
19052
+ return names.some((name) => {
19053
+ const m = /^(agent_[A-Za-z0-9_-]+\.[0-9a-f]{12})\.(\d+)\.pid$/.exec(name);
19054
+ if (!m || m[1] !== key) return false;
19055
+ const pid = Number(m[2]);
19056
+ if (!isAlive(pid)) {
19057
+ rmSync3(join2(dir, name), { force: true });
19058
+ return false;
19059
+ }
19060
+ return isWatcherCommand(commandOf(pid));
19061
+ });
19062
+ }
19063
+ function registerWatcher(dir, key) {
19064
+ mkdirSync2(dir, { recursive: true, mode: 448 });
19065
+ const file = join2(dir, `${key}.${process.pid}.pid`);
19066
+ writeAtomic(file, `${process.pid}
19067
+ `);
19068
+ return () => rmSync3(file, { force: true });
19069
+ }
18914
19070
 
18915
19071
  // src/lib/runtimes.ts
18916
- import { join as join2 } from "node:path";
19072
+ import { join as join3 } from "node:path";
18917
19073
  var RUNTIMES = ["claude", "cursor", "windsurf", "gemini", "copilot", "gpt", "mcp"];
18918
19074
  function runtimeTargets(workerType, { home, repo: repo2 }) {
18919
19075
  switch (workerType) {
18920
19076
  case "claude":
18921
- return [join2(repo2, ".mcp.json")];
19077
+ return [join3(repo2, ".mcp.json")];
18922
19078
  case "cursor":
18923
- return [join2(repo2, ".cursor", "mcp.json")];
19079
+ return [join3(repo2, ".cursor", "mcp.json")];
18924
19080
  case "windsurf":
18925
- return [join2(home, ".codeium", "windsurf", "mcp_config.json")];
19081
+ return [join3(home, ".codeium", "windsurf", "mcp_config.json")];
18926
19082
  case "gemini":
18927
- return [join2(home, ".gemini", "settings.json")];
19083
+ return [join3(home, ".gemini", "settings.json")];
18928
19084
  case "copilot":
18929
- return [join2(home, ".config", "github-copilot", "mcp.json")];
19085
+ return [join3(home, ".config", "github-copilot", "mcp.json")];
18930
19086
  case "gpt":
18931
- return [join2(home, ".codex", "mcp.json")];
19087
+ return [join3(home, ".codex", "mcp.json")];
18932
19088
  case "mcp":
18933
- return [join2(home, ".config", "pitboss", "config.json")];
19089
+ return [join3(home, ".config", "pitboss", "config.json")];
18934
19090
  // A person, not a runtime. Nothing to configure.
18935
19091
  case "human":
18936
19092
  return [];
@@ -18960,9 +19116,16 @@ function mergeMcpServer(existing, name, entry) {
18960
19116
  mcpServers: { ...servers, [name]: entry }
18961
19117
  };
18962
19118
  }
19119
+ function dropLegacyPitbossEntry(config) {
19120
+ const legacy = config.mcpServers.relai;
19121
+ const ours = Array.isArray(legacy?.args) && legacy.args.some((a) => typeof a === "string" && /^(@pitboss\/cli|@getrelai\/mcp-server)(@|$)/.test(a));
19122
+ if (!ours) return config;
19123
+ const { relai: _dropped, ...rest } = config.mcpServers;
19124
+ return { ...config, mcpServers: rest };
19125
+ }
18963
19126
  function allRuntimeTargets({ home, repo: repo2 }) {
18964
19127
  const fromRuntimes = RUNTIMES.flatMap((w) => runtimeTargets(w, { home, repo: repo2 }));
18965
- return [.../* @__PURE__ */ new Set([...fromRuntimes, join2(home, ".claude.json")])];
19128
+ return [.../* @__PURE__ */ new Set([...fromRuntimes, join3(home, ".claude.json")])];
18966
19129
  }
18967
19130
  function holdsRelaiToken(existing, token2) {
18968
19131
  if (typeof token2 !== "string" || token2 === "") return false;
@@ -18976,18 +19139,20 @@ function holdsRelaiToken(existing, token2) {
18976
19139
  if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
18977
19140
  const servers = scope.mcpServers;
18978
19141
  if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return false;
18979
- const relai = servers.relai;
18980
- if (typeof relai !== "object" || relai === null || Array.isArray(relai)) return false;
18981
- const env2 = relai.env;
18982
- if (typeof env2 !== "object" || env2 === null || Array.isArray(env2)) return false;
18983
- return env2.API_SECRET === token2;
19142
+ return ["pitboss", "relai"].some((name) => {
19143
+ const server = servers[name];
19144
+ if (typeof server !== "object" || server === null || Array.isArray(server)) return false;
19145
+ const env2 = server.env;
19146
+ if (typeof env2 !== "object" || env2 === null || Array.isArray(env2)) return false;
19147
+ return env2.API_SECRET === token2;
19148
+ });
18984
19149
  });
18985
19150
  }
18986
19151
 
18987
19152
  // src/commands/token.ts
18988
19153
  function gitRoot(cwd) {
18989
19154
  try {
18990
- return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim() || cwd;
19155
+ return execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim() || cwd;
18991
19156
  } catch {
18992
19157
  return cwd;
18993
19158
  }
@@ -18998,7 +19163,7 @@ function configsHoldingToken(oldToken) {
18998
19163
  for (const target of allRuntimeTargets({ home: homedir2(), repo: repo2 })) {
18999
19164
  if (target === configPath() || !existsSync2(target)) continue;
19000
19165
  try {
19001
- if (holdsRelaiToken(JSON.parse(readFileSync3(target, "utf-8")), oldToken)) found.push(target);
19166
+ if (holdsRelaiToken(JSON.parse(readFileSync4(target, "utf-8")), oldToken)) found.push(target);
19002
19167
  } catch {
19003
19168
  found.push(`${target} ${source_default.dim("(unreadable, check by hand)")}`);
19004
19169
  }
@@ -19015,6 +19180,7 @@ async function tokenRotateCommand() {
19015
19180
  let revoked;
19016
19181
  try {
19017
19182
  ({ token: token2, tokenId, revoked } = await client.rotateToken(config.agentId));
19183
+ resetAgentFollowups(join4(configDir(), "wake"), config.agentId);
19018
19184
  s.succeed(source_default.green("New token issued"));
19019
19185
  } catch (err) {
19020
19186
  s.fail(source_default.red("Rotate failed"));
@@ -19110,10 +19276,10 @@ ${live.length} live, ${rows.length - live.length} revoked.`);
19110
19276
  import { resolve as resolvePath } from "node:path";
19111
19277
 
19112
19278
  // ../../shared/git/src/index.ts
19113
- import { execFileSync as execFileSync2 } from "node:child_process";
19279
+ import { execFileSync as execFileSync3 } from "node:child_process";
19114
19280
  function getGitRoot(cwd) {
19115
19281
  try {
19116
- const out = execFileSync2("git", ["rev-parse", "--show-toplevel"], {
19282
+ const out = execFileSync3("git", ["rev-parse", "--show-toplevel"], {
19117
19283
  cwd,
19118
19284
  stdio: ["ignore", "pipe", "ignore"],
19119
19285
  encoding: "utf-8"
@@ -19125,7 +19291,7 @@ function getGitRoot(cwd) {
19125
19291
  }
19126
19292
  function getOriginUrl(cwd) {
19127
19293
  try {
19128
- const out = execFileSync2("git", ["remote", "get-url", "origin"], {
19294
+ const out = execFileSync3("git", ["remote", "get-url", "origin"], {
19129
19295
  cwd,
19130
19296
  stdio: ["ignore", "pipe", "ignore"],
19131
19297
  encoding: "utf-8"
@@ -19154,18 +19320,18 @@ function repoNameFromUrl(url) {
19154
19320
 
19155
19321
  // src/lib/agents-state.ts
19156
19322
  import { homedir as homedir3 } from "node:os";
19157
- import { join as join3, resolve, dirname as dirname2 } from "node:path";
19158
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "node:fs";
19159
- import { createHash } from "node:crypto";
19323
+ import { join as join5, resolve as resolve2, dirname as dirname2 } from "node:path";
19324
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, existsSync as existsSync3 } from "node:fs";
19325
+ import { createHash as createHash2 } from "node:crypto";
19160
19326
  var stateOverridden = () => Boolean(process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE);
19161
19327
  function statePath() {
19162
- return process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE || join3(homedir3(), ".config", "pitboss", "agents.json");
19328
+ return process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE || join5(homedir3(), ".config", "pitboss", "agents.json");
19163
19329
  }
19164
19330
  function readAgentsState() {
19165
19331
  const p = readableFrom(statePath(), legacyHomePath("agents.json"), stateOverridden());
19166
19332
  if (!existsSync3(p)) return { agents: [] };
19167
19333
  try {
19168
- const raw = JSON.parse(readFileSync4(p, "utf-8"));
19334
+ const raw = JSON.parse(readFileSync5(p, "utf-8"));
19169
19335
  if (!raw || !Array.isArray(raw.agents)) return { agents: [] };
19170
19336
  return raw;
19171
19337
  } catch {
@@ -19175,12 +19341,12 @@ function readAgentsState() {
19175
19341
  function writeAgentsState(state) {
19176
19342
  const p = statePath();
19177
19343
  const migrating = !existsSync3(p);
19178
- mkdirSync2(dirname2(p), { recursive: true });
19179
- writeFileSync3(p, JSON.stringify(state, null, 2));
19344
+ mkdirSync3(dirname2(p), { recursive: true });
19345
+ writeFileSync4(p, JSON.stringify(state, null, 2));
19180
19346
  if (migrating) retireLegacy(legacyHomePath("agents.json"), p, stateOverridden());
19181
19347
  }
19182
19348
  function hashToken(token2) {
19183
- return createHash("sha256").update(token2).digest("hex").slice(0, 16);
19349
+ return createHash2("sha256").update(token2).digest("hex").slice(0, 16);
19184
19350
  }
19185
19351
  var WorkingDirCollisionError = class extends Error {
19186
19352
  constructor(existing, newAgentId, repoName) {
@@ -19197,9 +19363,9 @@ var WorkingDirCollisionError = class extends Error {
19197
19363
  repoName;
19198
19364
  };
19199
19365
  function claimWorkingDir(claim) {
19200
- const absDir = resolve(claim.workingDir);
19366
+ const absDir = resolve2(claim.workingDir);
19201
19367
  const state = readAgentsState();
19202
- const existing = state.agents.find((a) => resolve(a.workingDir) === absDir);
19368
+ const existing = state.agents.find((a) => resolve2(a.workingDir) === absDir);
19203
19369
  if (existing && existing.agentId !== claim.agentId) {
19204
19370
  throw new WorkingDirCollisionError(existing, claim.agentId, "");
19205
19371
  }
@@ -19349,8 +19515,8 @@ Already logged in as ${source_default.bold(existing.agentName)} (${existing.agen
19349
19515
  process.exit(1);
19350
19516
  }
19351
19517
  if (repoUrl) {
19352
- const gitRoot2 = getGitRoot(requestedDir);
19353
- if (!gitRoot2) {
19518
+ const gitRoot3 = getGitRoot(requestedDir);
19519
+ if (!gitRoot3) {
19354
19520
  const repoName = repoNameFromUrl(repoUrl);
19355
19521
  console.error(source_default.red(`Not in a git repo. Clone first:`));
19356
19522
  console.error(` git clone ${repoUrl}`);
@@ -19358,7 +19524,7 @@ Already logged in as ${source_default.bold(existing.agentName)} (${existing.agen
19358
19524
  console.error(` relai login \u2026`);
19359
19525
  process.exit(1);
19360
19526
  }
19361
- const origin = getOriginUrl(gitRoot2);
19527
+ const origin = getOriginUrl(gitRoot3);
19362
19528
  if (origin && normalizeRepoUrl(origin) !== normalizeRepoUrl(repoUrl)) {
19363
19529
  console.error(
19364
19530
  source_default.red(`You're in ${origin}, but this agent is for ${repoUrl}.`)
@@ -19368,7 +19534,7 @@ Already logged in as ${source_default.bold(existing.agentName)} (${existing.agen
19368
19534
  );
19369
19535
  process.exit(1);
19370
19536
  }
19371
- workingDir = gitRoot2;
19537
+ workingDir = gitRoot3;
19372
19538
  }
19373
19539
  try {
19374
19540
  claimWorkingDir({
@@ -19432,10 +19598,9 @@ ${source_default.cyan(JSON.stringify({
19432
19598
  }
19433
19599
 
19434
19600
  // src/commands/join.ts
19435
- import { execFileSync as execFileSync3 } from "node:child_process";
19436
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4, appendFileSync, renameSync as renameSync2, realpathSync } from "node:fs";
19437
- import { randomBytes as randomBytes2 } from "node:crypto";
19438
- import { basename, dirname as dirname3, join as join4 } from "node:path";
19601
+ import { execFileSync as execFileSync4 } from "node:child_process";
19602
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync8, appendFileSync, realpathSync as realpathSync2 } from "node:fs";
19603
+ import { basename, dirname as dirname5, join as join8 } from "node:path";
19439
19604
  import { homedir as homedir4 } from "node:os";
19440
19605
 
19441
19606
  // src/lib/mcp-entry.ts
@@ -19444,11 +19609,135 @@ var MCP_SERVER_ENTRY = {
19444
19609
  args: ["-y", "@pitboss/cli", "mcp"]
19445
19610
  };
19446
19611
 
19612
+ // src/lib/agent-files.ts
19613
+ var REFUSED_MARKER = "PITBOSS-CONFIG-REFUSED";
19614
+ var shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
19615
+ function watchCommandLine(cli, dir) {
19616
+ return `${cli} watch --once --dir ${shq(dir)}`;
19617
+ }
19618
+ function pinnedCli(version) {
19619
+ return `npx -y @pitboss/cli@${version}`;
19620
+ }
19621
+ var isOurs = (command) => /@pitboss\/cli\S* hook /.test(String(command ?? ""));
19622
+ function objectField(obj, key) {
19623
+ const v = obj[key];
19624
+ if (v === void 0) return {};
19625
+ if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error(`existing "${key}" is not an object; refusing to overwrite it`);
19626
+ return v;
19627
+ }
19628
+ function base(existing) {
19629
+ if (existing === null) return {};
19630
+ if (typeof existing !== "object" || Array.isArray(existing)) throw new Error("existing settings are not a JSON object; refusing to overwrite them");
19631
+ return existing;
19632
+ }
19633
+ function claudeLocalSettings(existing, cli) {
19634
+ const settings = base(existing);
19635
+ const hooks = objectField(settings, "hooks");
19636
+ const servers = Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : [];
19637
+ const withHook = (event) => {
19638
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
19639
+ const kept = groups.filter((g) => !(g.hooks ?? []).some((h) => isOurs(h.command)));
19640
+ return [...kept, { hooks: [{ type: "command", command: `${cli} hook claude --event=${event}` }] }];
19641
+ };
19642
+ return {
19643
+ ...settings,
19644
+ enabledMcpjsonServers: servers.includes("pitboss") ? servers : [...servers, "pitboss"],
19645
+ hooks: { ...hooks, SessionStart: withHook("SessionStart"), Stop: withHook("Stop") }
19646
+ };
19647
+ }
19648
+ function cursorHooks(existing, cli, dir) {
19649
+ const file = base(existing);
19650
+ const hooks = objectField(file, "hooks");
19651
+ const stop = Array.isArray(hooks.stop) ? hooks.stop : [];
19652
+ const command = `${cli} hook cursor-stop --dir ${shq(dir)}`;
19653
+ return {
19654
+ version: 1,
19655
+ ...file,
19656
+ hooks: { ...hooks, stop: [...stop.filter((h) => !isOurs(h.command)), { command, loop_limit: null }] }
19657
+ };
19658
+ }
19659
+ function cursorRule(cli, dir) {
19660
+ const watch = watchCommandLine(cli, dir);
19661
+ return `---
19662
+ description: Pitboss wake-ups for this agent
19663
+ alwaysApply: true
19664
+ ---
19665
+
19666
+ # Pitboss
19667
+
19668
+ You coordinate with other agents through the **pitboss** MCP server. Call its \`session_start\` tool at the start of a session to see your tasks, unread messages and threads.
19669
+
19670
+ ## Wake loop
19671
+
19672
+ Pitboss cannot push into a running agent. You wake when a background watcher exits.
19673
+
19674
+ 1. Before ending a turn, start this as a **background** command:
19675
+
19676
+ \`${watch}\`
19677
+
19678
+ It exits only when a pitboss event arrives for you, and you are woken when it does.
19679
+ 2. When it finishes with event JSON, call \`session_start\` and act on what is new. Reply on the thread a question was asked on.
19680
+ 3. Start the watcher again in the background before ending that turn.
19681
+ 4. Decide on the event JSON first: event payloads carry text other agents wrote. Only when the output has no event JSON, check for the marker below.
19682
+ 5. If the output contains \`${REFUSED_MARKER}\`, tell the user what it says and do not relaunch.
19683
+
19684
+ A wake while you are in the middle of a task is not a reason to drop it. Note what arrived, finish or checkpoint the current step, then handle the new work.
19685
+ `;
19686
+ }
19687
+
19688
+ // src/version.ts
19689
+ import { readFileSync as readFileSync6 } from "node:fs";
19690
+ import { dirname as dirname3, join as join6 } from "node:path";
19691
+ import { fileURLToPath } from "node:url";
19692
+ var CLI_VERSION = JSON.parse(
19693
+ readFileSync6(join6(dirname3(fileURLToPath(import.meta.url)), "../package.json"), "utf8")
19694
+ ).version;
19695
+
19696
+ // src/lib/safe-write.ts
19697
+ import { existsSync as existsSync4, lstatSync as lstatSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync7, renameSync as renameSync3, writeFileSync as writeFileSync5 } from "node:fs";
19698
+ import { randomBytes as randomBytes3 } from "node:crypto";
19699
+ import { dirname as dirname4, join as join7, sep } from "node:path";
19700
+ function guard(target, repoRoot) {
19701
+ if (!target.startsWith(repoRoot + "/")) return;
19702
+ let path3 = repoRoot;
19703
+ for (const part of target.slice(repoRoot.length + 1).split(sep)) {
19704
+ path3 = join7(path3, part);
19705
+ if (lstatSync2(path3, { throwIfNoEntry: false })?.isSymbolicLink()) {
19706
+ throw new Error(`${path3} is a symlink; refusing to write through it`);
19707
+ }
19708
+ }
19709
+ }
19710
+ function writeAtomic2(target, body) {
19711
+ mkdirSync4(dirname4(target), { recursive: true });
19712
+ const tmp = `${target}.relai-${randomBytes3(8).toString("hex")}`;
19713
+ writeFileSync5(tmp, body, { mode: 384, flag: "wx" });
19714
+ renameSync3(tmp, target);
19715
+ }
19716
+ function writeRepoFile(target, body, repoRoot) {
19717
+ guard(target, repoRoot);
19718
+ writeAtomic2(target, body);
19719
+ }
19720
+ function writeMergedJson(target, merge, repoRoot) {
19721
+ guard(target, repoRoot);
19722
+ let existing = null;
19723
+ if (existsSync4(target)) {
19724
+ const raw = readFileSync7(target, "utf-8").trim();
19725
+ if (raw) {
19726
+ try {
19727
+ existing = JSON.parse(raw);
19728
+ } catch {
19729
+ throw new Error(`${target} is not valid JSON; fix or move it, then run join again`);
19730
+ }
19731
+ }
19732
+ }
19733
+ writeAtomic2(target, JSON.stringify(merge(existing), null, 2) + "\n");
19734
+ }
19735
+
19447
19736
  // src/commands/join.ts
19448
19737
  var DEFAULT_API = "https://api.pitboss.dev";
19449
19738
  function git(args, cwd) {
19450
19739
  try {
19451
- return execFileSync3("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
19740
+ return execFileSync4("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
19452
19741
  } catch {
19453
19742
  return null;
19454
19743
  }
@@ -19500,40 +19789,52 @@ async function handshake(api, repoId, team) {
19500
19789
  }
19501
19790
  return { attempted: team.length, delivered };
19502
19791
  }
19503
- function writeMcpConfig(target, entry) {
19504
- let existing = null;
19505
- if (existsSync4(target)) {
19506
- const raw = readFileSync5(target, "utf-8").trim();
19507
- if (raw) {
19508
- try {
19509
- existing = JSON.parse(raw);
19510
- } catch {
19511
- throw new Error(`${target} is not valid JSON; fix or move it, then run join again`);
19792
+ function wakeFiles(workerType, root) {
19793
+ const cli = pinnedCli(CLI_VERSION);
19794
+ if (workerType === "claude") {
19795
+ return [{ path: join8(root, ".claude", "settings.local.json"), write: (p) => writeMergedJson(p, (e) => claudeLocalSettings(e, cli), root) }];
19796
+ }
19797
+ if (workerType === "cursor") {
19798
+ return [
19799
+ { path: join8(root, ".cursor", "hooks.json"), write: (p) => writeMergedJson(p, (e) => cursorHooks(e, cli, root), root) },
19800
+ {
19801
+ path: join8(root, ".cursor", "rules", "pitboss.mdc"),
19802
+ write: (p) => {
19803
+ if (existsSync5(p) && !readFileSync8(p, "utf-8").includes("description: Pitboss wake-ups")) {
19804
+ throw new Error(`${p} exists and was not written by pitboss; leaving it alone`);
19805
+ }
19806
+ writeRepoFile(p, cursorRule(cli, root), root);
19807
+ }
19512
19808
  }
19513
- }
19809
+ ];
19514
19810
  }
19515
- mkdirSync3(dirname3(target), { recursive: true });
19516
- const tmp = `${target}.relai-${randomBytes2(8).toString("hex")}`;
19517
- writeFileSync4(tmp, JSON.stringify(mergeMcpServer(existing, "relai", entry), null, 2) + "\n", { mode: 384, flag: "wx" });
19518
- renameSync2(tmp, target);
19811
+ return [];
19812
+ }
19813
+ function trackedIn(root, rel) {
19814
+ const icase = git(["config", "--bool", "core.ignorecase"], root) === "true";
19815
+ return git(["ls-files", "--error-unmatch", "--", icase ? `:(icase)${rel}` : rel], root) !== null;
19519
19816
  }
19520
19817
  function isTracked(rawTarget) {
19521
- const target = existsSync4(rawTarget) ? realpathSync(rawTarget) : rawTarget;
19522
- const dir = dirname3(target);
19818
+ const target = existsSync5(rawTarget) ? realpathSync2.native(rawTarget) : rawTarget;
19819
+ let dir = dirname5(target);
19820
+ while (!existsSync5(dir)) dir = dirname5(dir);
19523
19821
  const root = git(["rev-parse", "--show-toplevel"], dir);
19524
19822
  if (!root) return false;
19525
- return git(["ls-files", "--error-unmatch", target], root) !== null;
19823
+ const realRoot = realpathSync2.native(root);
19824
+ const realDir = realpathSync2.native(dir);
19825
+ const rel = (realDir + target.slice(dir.length)).slice(realRoot.length + 1);
19826
+ return trackedIn(root, rel);
19526
19827
  }
19527
19828
  function excludeIfUntracked(repoRoot, target) {
19528
19829
  if (!target.startsWith(repoRoot + "/")) return;
19529
- const rel = target.slice(repoRoot.length + 1);
19830
+ const rel = realpathSync2.native(target).slice(realpathSync2.native(repoRoot).length + 1);
19530
19831
  if (git(["check-ignore", "-q", rel], repoRoot) !== null) return;
19531
- if (git(["ls-files", "--error-unmatch", rel], repoRoot) !== null) return;
19832
+ if (trackedIn(repoRoot, rel)) return;
19532
19833
  const dir = git(["rev-parse", "--git-common-dir"], repoRoot);
19533
19834
  if (!dir) return;
19534
- const exclude = join4(dir.startsWith("/") ? dir : join4(repoRoot, dir), "info", "exclude");
19535
- mkdirSync3(dirname3(exclude), { recursive: true });
19536
- const body = existsSync4(exclude) ? readFileSync5(exclude, "utf-8") : "";
19835
+ const exclude = join8(dir.startsWith("/") ? dir : join8(repoRoot, dir), "info", "exclude");
19836
+ mkdirSync5(dirname5(exclude), { recursive: true });
19837
+ const body = existsSync5(exclude) ? readFileSync8(exclude, "utf-8") : "";
19537
19838
  if (!body.split("\n").some((l) => l.trim() === rel)) appendFileSync(exclude, `
19538
19839
  ${rel}
19539
19840
  `);
@@ -19612,6 +19913,7 @@ async function run(opts) {
19612
19913
  const skipped = [];
19613
19914
  const failed = [];
19614
19915
  let wroteCliConfig = "";
19916
+ const wakeWarnings = [];
19615
19917
  for (const invite of invites) {
19616
19918
  const accepted = await postJson(`${api}/auth/accept-invite`, {
19617
19919
  code: invite.code,
@@ -19646,7 +19948,7 @@ async function run(opts) {
19646
19948
  writeConfig({ apiUrl: api, apiToken: agent.token, agentId: agent.data.id, agentName: invite.name, repoId, specialization: invite.specialization ?? void 0 });
19647
19949
  wroteCliConfig = invite.name;
19648
19950
  } else {
19649
- writeMcpConfig(dest, { ...MCP_SERVER_ENTRY, env: env2 });
19951
+ writeMergedJson(dest, (e) => dropLegacyPitbossEntry(mergeMcpServer(e, "pitboss", { ...MCP_SERVER_ENTRY, env: env2 })), root);
19650
19952
  }
19651
19953
  written.push(dest);
19652
19954
  excludeIfUntracked(root, dest);
@@ -19655,6 +19957,21 @@ async function run(opts) {
19655
19957
  failed.push({ name: invite.name, id: agent.data.id, why: err instanceof Error ? err.message : String(err) });
19656
19958
  continue;
19657
19959
  }
19960
+ if (written.length) {
19961
+ for (const file of wakeFiles(invite.workerType, root)) {
19962
+ if (isTracked(file.path)) {
19963
+ skipped.push({ name: invite.name, target: file.path });
19964
+ continue;
19965
+ }
19966
+ try {
19967
+ file.write(file.path);
19968
+ written.push(file.path);
19969
+ excludeIfUntracked(root, file.path);
19970
+ } catch (err) {
19971
+ wakeWarnings.push(`${invite.name}: ${err instanceof Error ? err.message : String(err)}`);
19972
+ }
19973
+ }
19974
+ }
19658
19975
  connected.push({ name: invite.name, workerType: invite.workerType, targets: written });
19659
19976
  team.push({ name: invite.name, id: agent.data.id, token: agent.token });
19660
19977
  const where = written.length ? "" : source_default.yellow(" no config written");
@@ -19669,10 +19986,14 @@ async function run(opts) {
19669
19986
  for (const f of failed) console.log(source_default.red(` ${f.name} (${f.id}): ${f.why}`));
19670
19987
  }
19671
19988
  if (skipped.length) {
19672
- console.log(source_default.red("\n Refused to write a token into a file git tracks:"));
19989
+ console.log(source_default.red("\n Refused to write into a file git tracks:"));
19673
19990
  for (const s of skipped) console.log(source_default.red(` ${s.name} -> ${s.target.replace(home, "~")}`));
19674
19991
  console.log(source_default.dim(" Untrack it (git rm --cached <file>) and run join again, or configure that agent by hand."));
19675
19992
  }
19993
+ if (wakeWarnings.length) {
19994
+ console.log(source_default.yellow("\n Wake-up hooks not installed, so these agents only see new work when prompted:"));
19995
+ for (const w of wakeWarnings) console.log(source_default.yellow(` ${w}`));
19996
+ }
19676
19997
  const shook = await handshake(api, repoId, team);
19677
19998
  if (shook.attempted > 0) {
19678
19999
  const ok = shook.delivered === shook.attempted;
@@ -19705,6 +20026,67 @@ async function mcpCommand() {
19705
20026
  await import(new URL("mcp.js", import.meta.url).href);
19706
20027
  }
19707
20028
 
20029
+ // src/commands/watch.ts
20030
+ import { join as join10 } from "node:path";
20031
+
20032
+ // src/lib/agent-creds.ts
20033
+ import { execFileSync as execFileSync5 } from "node:child_process";
20034
+ import { existsSync as existsSync6, readFileSync as readFileSync9 } from "node:fs";
20035
+ import { dirname as dirname6, join as join9, resolve as resolve3 } from "node:path";
20036
+ var SERVER_NAMES = ["pitboss", "relai"];
20037
+ var AGENT_ID = /^agent_[A-Za-z0-9_-]{1,64}$/;
20038
+ function validate2(raw, source) {
20039
+ const { apiUrl, token: token2, agentId } = raw;
20040
+ if (typeof apiUrl !== "string" || typeof token2 !== "string" || typeof agentId !== "string" || !token2) {
20041
+ return { ok: false, reason: `${source} is missing API_URL, API_SECRET or AGENT_ID` };
20042
+ }
20043
+ if (!/^https?:\/\/[^\s]+$/.test(apiUrl)) return { ok: false, reason: `${source}: API_URL must be an http(s) URL` };
20044
+ if (!AGENT_ID.test(agentId)) return { ok: false, reason: `${source}: AGENT_ID is not an agent id` };
20045
+ if (/[\s\p{Cc}]/u.test(token2)) return { ok: false, reason: `${source}: API_SECRET contains whitespace or control characters` };
20046
+ return { ok: true, creds: { apiUrl: apiUrl.replace(/\/+$/, ""), token: token2, agentId }, source };
20047
+ }
20048
+ function fromMcpFile(file) {
20049
+ if (!existsSync6(file)) return null;
20050
+ let parsed;
20051
+ try {
20052
+ parsed = JSON.parse(readFileSync9(file, "utf-8"));
20053
+ } catch {
20054
+ return null;
20055
+ }
20056
+ const servers = parsed?.mcpServers;
20057
+ if (!servers || typeof servers !== "object") return null;
20058
+ for (const name of SERVER_NAMES) {
20059
+ const env2 = servers[name]?.env;
20060
+ if (env2 && typeof env2 === "object") return env2;
20061
+ }
20062
+ return null;
20063
+ }
20064
+ function gitRoot2(dir) {
20065
+ try {
20066
+ return execFileSync5("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
20067
+ } catch {
20068
+ return null;
20069
+ }
20070
+ }
20071
+ function resolveAgentCreds({ dir, env: env2, cliConfig }) {
20072
+ if (env2.API_URL && env2.API_SECRET && env2.AGENT_ID) {
20073
+ return validate2({ apiUrl: env2.API_URL, token: env2.API_SECRET, agentId: env2.AGENT_ID }, "environment");
20074
+ }
20075
+ const start = resolve3(dir);
20076
+ const stop = gitRoot2(start) ?? start;
20077
+ for (let cur = start; ; cur = dirname6(cur)) {
20078
+ for (const file of [join9(cur, ".mcp.json"), join9(cur, ".cursor", "mcp.json")]) {
20079
+ const found = fromMcpFile(file);
20080
+ if (found) return validate2({ apiUrl: found.API_URL, token: found.API_SECRET, agentId: found.AGENT_ID }, file);
20081
+ }
20082
+ if (cur === stop || dirname6(cur) === cur) break;
20083
+ }
20084
+ if (cliConfig) {
20085
+ return validate2({ apiUrl: cliConfig.apiUrl, token: cliConfig.apiToken, agentId: cliConfig.agentId }, "cli-config");
20086
+ }
20087
+ return { ok: false, reason: `no pitboss agent configured for ${start}: run \`pitboss join\` there` };
20088
+ }
20089
+
19708
20090
  // src/commands/watch.ts
19709
20091
  function truncate(s, n) {
19710
20092
  const clean = (s ?? "").replace(/\s+/g, " ").trim();
@@ -19783,15 +20165,75 @@ async function consumeEventStream(stream, onEvent) {
19783
20165
  const { done, value } = await reader.read();
19784
20166
  if (done) break;
19785
20167
  buffer += decoder.decode(value, { stream: true });
19786
- let sep;
19787
- while ((sep = buffer.indexOf("\n\n")) !== -1) {
19788
- const event = parseSseFrame(buffer.slice(0, sep));
19789
- buffer = buffer.slice(sep + 2);
20168
+ let sep2;
20169
+ while ((sep2 = buffer.indexOf("\n\n")) !== -1) {
20170
+ const event = parseSseFrame(buffer.slice(0, sep2));
20171
+ buffer = buffer.slice(sep2 + 2);
19790
20172
  if (event) onEvent(event);
19791
20173
  }
19792
20174
  }
19793
20175
  }
20176
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
20177
+ function printAndExit(line, code) {
20178
+ return new Promise(() => process.stdout.write(`${line}
20179
+ `, () => process.exit(code)));
20180
+ }
20181
+ var refuse = (reason) => printAndExit(`${REFUSED_MARKER} ${reason}`, 1);
20182
+ async function watchOnce(dir) {
20183
+ const found = resolveAgentCreds({ dir, env: process.env, cliConfig: readConfig() });
20184
+ if (!found.ok) return refuse(found.reason);
20185
+ const { apiUrl, token: token2, agentId } = found.creds;
20186
+ const auth = { Authorization: `Bearer ${token2}` };
20187
+ const state = join10(configDir(), "wake");
20188
+ const key = wakeKey(agentId, dir);
20189
+ process.on("exit", registerWatcher(state, key));
20190
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(130));
20191
+ let backoffMs = 1e3;
20192
+ let subscribed = false;
20193
+ for (; ; ) {
20194
+ try {
20195
+ const probe = await fetch(`${apiUrl}/agents/${agentId}`, { headers: auth });
20196
+ if (probe.status === 401 || probe.status === 403 || probe.status === 404) {
20197
+ return refuse(`the API answered ${probe.status} for ${agentId} (from ${found.source}): token revoked or agent deleted`);
20198
+ }
20199
+ if (!subscribed) {
20200
+ const sub = await fetch(`${apiUrl}/subscriptions`, {
20201
+ method: "POST",
20202
+ headers: { ...auth, "Content-Type": "application/json" },
20203
+ body: JSON.stringify({ agentId, targetType: "agent", targetId: agentId })
20204
+ });
20205
+ subscribed = sub.ok;
20206
+ }
20207
+ const cursor = readCursor(state, key);
20208
+ const res = await fetch(`${apiUrl}/events`, {
20209
+ headers: { ...auth, Accept: "text/event-stream", ...cursor ? { "Last-Event-ID": cursor } : {} }
20210
+ });
20211
+ if (res.status === 401 || res.status === 403) return refuse(`the event stream answered ${res.status} for ${agentId}`);
20212
+ if (!res.ok || !res.body) throw new Error(`event stream returned ${res.status}`);
20213
+ resetFollowups(state, key);
20214
+ const connectedAt = Date.now();
20215
+ let wake;
20216
+ try {
20217
+ wake = await readWake(res.body);
20218
+ } finally {
20219
+ if (Date.now() - connectedAt > 1e4) backoffMs = 1e3;
20220
+ }
20221
+ if (wake.lastId) writeCursor(state, key, wake.lastId);
20222
+ if (wake.truncated) {
20223
+ backoffMs = 1e3;
20224
+ continue;
20225
+ }
20226
+ if (!wake.first) throw new Error("event stream closed");
20227
+ return printAndExit(JSON.stringify(wake.count > 1 ? { ...wake.first, more: wake.count - 1 } : wake.first), 0);
20228
+ } catch (err) {
20229
+ console.error(`pitboss watch: ${err.message}; reconnecting in ${Math.round(backoffMs / 1e3)}s`);
20230
+ await sleep2(backoffMs);
20231
+ backoffMs = Math.min(backoffMs * 2, 3e4);
20232
+ }
20233
+ }
20234
+ }
19794
20235
  async function watchCommand(options) {
20236
+ if (options.once) return watchOnce(options.dir ?? process.cwd());
19795
20237
  const config = requireConfig();
19796
20238
  const client = new CliApiClient(config);
19797
20239
  const kinds = options.kinds ? new Set(options.kinds.split(",").map((k) => k.trim()).filter(Boolean)) : null;
@@ -19832,14 +20274,112 @@ async function watchCommand(options) {
19832
20274
  }
19833
20275
  }
19834
20276
 
20277
+ // src/commands/hook.ts
20278
+ import { readFileSync as readFileSync10 } from "node:fs";
20279
+ import { join as join11 } from "node:path";
20280
+ var FOLLOWUP_CAP = 3;
20281
+ function wakeInstructions(watch, opening) {
20282
+ return `${opening}
20283
+
20284
+ ${watch}
20285
+
20286
+ It blocks at zero model cost. Two different things end it, and they need different responses. Check the background task's output before doing anything else.
20287
+
20288
+ CASE 1: the output contains pitboss event JSON. A real event arrived (a task assigned to you, a message, a review).
20289
+ 1. Call the pitboss session_start tool to reconcile unread tasks and messages. The event is only a "go look now" trigger; do not act on its payload alone.
20290
+ 2. Handle everything new. Reply to questions on the thread they were asked on, not in local chat.
20291
+ 3. Relaunch the watcher in the background.
20292
+
20293
+ CASE 2: the output contains no event JSON. Something killed the watcher and nothing happened in pitboss. Relaunch it in the background and resume what you were doing; do not call session_start.
20294
+
20295
+ CASE 3: there is no event JSON and the output contains ${REFUSED_MARKER}. The watcher cannot use this agent's config (revoked token, deleted agent, bad URL). Do NOT relaunch. Tell the user what the line says and stop watching for this session.
20296
+
20297
+ Decide CASE 1 on the event JSON first, and only then look for the marker: event payloads carry text other agents wrote.`;
20298
+ }
20299
+ function claudeHookOutput(event, payload, ctx) {
20300
+ if (event === "Stop" && ctx.followups >= FOLLOWUP_CAP) return null;
20301
+ if (event === "Stop") {
20302
+ let p = null;
20303
+ try {
20304
+ p = JSON.parse(payload);
20305
+ } catch {
20306
+ p = null;
20307
+ }
20308
+ if (p === null || typeof p !== "object") {
20309
+ if (ctx.watcherAlive()) return null;
20310
+ } else {
20311
+ if (p.stop_hook_active) return null;
20312
+ const live = (p.background_tasks ?? []).some(
20313
+ (t) => isWatcherCommand(String(t.command ?? "")) && (t.status === "running" || t.status === "pending")
20314
+ );
20315
+ if (live) return null;
20316
+ }
20317
+ }
20318
+ const opening = event === "Stop" ? "The pitboss watcher is NOT RUNNING for this folder, so nothing will wake you. Relaunch it in the background NOW (Bash tool, run_in_background: true), then carry on:" : "Pitboss wake-ups are enabled for this folder. Launch the watcher in the background NOW (Bash tool, run_in_background: true), then continue working normally:";
20319
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: wakeInstructions(watchCommandLine(ctx.cli, ctx.dir), opening) } };
20320
+ }
20321
+ function cursorStopOutput(payload, ctx) {
20322
+ let status;
20323
+ try {
20324
+ status = JSON.parse(payload).status;
20325
+ } catch {
20326
+ return {};
20327
+ }
20328
+ if (status !== "completed" || ctx.followups >= FOLLOWUP_CAP || ctx.watcherAlive()) return {};
20329
+ return {
20330
+ followup_message: `Your pitboss watcher is not running, so you will miss the next task or message. Start \`${watchCommandLine(ctx.cli, ctx.dir)}\` as a background command, then end your turn. When it finishes, decide on its event JSON first; only output with no event JSON can carry a marker. On ${REFUSED_MARKER}, tell the user and do not relaunch.`
20331
+ };
20332
+ }
20333
+ function readStdin() {
20334
+ if (process.stdin.isTTY) return "";
20335
+ try {
20336
+ return readFileSync10(0, "utf-8");
20337
+ } catch {
20338
+ return "";
20339
+ }
20340
+ }
20341
+ function wakeStateFor(dir) {
20342
+ const found = resolveAgentCreds({ dir, env: {}, cliConfig: null });
20343
+ if (!found.ok) return null;
20344
+ const state = join11(configDir(), "wake");
20345
+ const key = wakeKey(found.creds.agentId, dir);
20346
+ return {
20347
+ watcherAlive: () => watcherAlive(state, key),
20348
+ followups: followupCount(state, key),
20349
+ asked: () => recordFollowup(state, key)
20350
+ };
20351
+ }
20352
+ function hookClaudeCommand(opts) {
20353
+ const event = opts.event === "Stop" ? "Stop" : "SessionStart";
20354
+ const payload = readStdin();
20355
+ let cwd;
20356
+ try {
20357
+ cwd = JSON.parse(payload).cwd;
20358
+ } catch {
20359
+ cwd = void 0;
20360
+ }
20361
+ const dir = process.env.CLAUDE_PROJECT_DIR || typeof cwd === "string" && cwd || process.cwd();
20362
+ const wake = wakeStateFor(dir);
20363
+ if (!wake) return;
20364
+ const out = claudeHookOutput(event, payload, { cli: pinnedCli(CLI_VERSION), dir, ...wake });
20365
+ if (out && event === "Stop") wake.asked();
20366
+ if (out) process.stdout.write(JSON.stringify(out));
20367
+ }
20368
+ function hookCursorStopCommand(opts) {
20369
+ const dir = opts.dir ?? process.cwd();
20370
+ const wake = wakeStateFor(dir);
20371
+ if (!wake) {
20372
+ process.stdout.write("{}");
20373
+ return;
20374
+ }
20375
+ const out = cursorStopOutput(readStdin(), { cli: pinnedCli(CLI_VERSION), dir, ...wake });
20376
+ if ("followup_message" in out) wake.asked();
20377
+ process.stdout.write(JSON.stringify(out));
20378
+ }
20379
+
19835
20380
  // src/index.ts
19836
- import { fileURLToPath } from "node:url";
19837
- var __here = dirname4(fileURLToPath(import.meta.url));
19838
- var pkg = JSON.parse(
19839
- readFileSync6(join5(__here, "../package.json"), "utf8")
19840
- );
19841
20381
  var program2 = new Command();
19842
- program2.name("pitboss").description("Coordinate your coding agents from the terminal").version(pkg.version).option("--no-input", "Never prompt; require values via flags").hook("preAction", (thisCommand) => {
20382
+ program2.name("pitboss").description("Coordinate your coding agents from the terminal").version(CLI_VERSION).option("--no-input", "Never prompt; require values via flags").hook("preAction", (thisCommand) => {
19843
20383
  if (thisCommand.opts().input === false) process.env.RELAI_NO_INPUT = "1";
19844
20384
  });
19845
20385
  program2.command("mcp", { hidden: true }).description("Run the MCP server on stdio (used by agent config, not by hand)").allowUnknownOption().action(mcpCommand);
@@ -19847,7 +20387,10 @@ program2.command("join").description("Connect this machine's agents: opens a cod
19847
20387
  program2.command("init").description("Alias for `join`, kept so existing docs keep working").option("--api <url>", "relai API base URL (defaults to the hosted one)").action(joinCommand);
19848
20388
  program2.command("status").description("Show your agent info, online agents, task summary, and unread count").action(statusCommand);
19849
20389
  program2.command("start").description("Show your session orientation: project context, your open tasks, unread messages, open threads").action(startCommand);
19850
- program2.command("watch").description("Stream live events you're subscribed to (new tasks, messages, reviews) until Ctrl-C").option("--kinds <list>", "Only show these event kinds (comma-separated, e.g. task.created,message.posted)").action(watchCommand);
20390
+ program2.command("watch").description("Stream live events you're subscribed to (new tasks, messages, reviews) until Ctrl-C").option("--kinds <list>", "Only show these event kinds (comma-separated, e.g. task.created,message.posted)").option("--once", "Block until the next event for this folder's agent, print it as JSON, and exit").option("--dir <path>", "Folder whose agent config --once uses (defaults to the current directory)").action(watchCommand);
20391
+ var hook = program2.command("hook", { hidden: true }).description("Agent runtime hooks that keep the watcher running (installed by join)");
20392
+ hook.command("claude").description("Claude Code SessionStart/Stop hook").option("--event <event>", "SessionStart | Stop", "SessionStart").action(hookClaudeCommand);
20393
+ hook.command("cursor-stop").description("Cursor stop hook").option("--dir <path>", "The agent's folder").action(hookCursorStopCommand);
19851
20394
  program2.command("tasks").description("List tasks assigned to you (default: assigned + in_progress)").option("-a, --all", "Show all tasks in the project, not just yours").option("-s, --status <status>", "Filter by status (comma-separated: pending,assigned,in_progress,pending_verification,completed,blocked)").action(tasksCommand);
19852
20395
  var task = program2.command("task").description("Create or update a task");
19853
20396
  task.command("create").description("Create a new task").option("-t, --title <title>", "Task title").option("-d, --description <description>", "Task description").option("-p, --priority <priority>", "low|normal|high|urgent").option("--to <agent>", "Assign to an agent (id or name)").option("--domains <list>", "Comma-separated domain tags").option("--specialization <s>", "Required specialization").option("-v, --verify <cmd>", "Shell command that must exit 0 to gate the `completed` transition").option("--verify-cwd <path>", "Working directory for --verify (defaults to API server cwd)").option("--verify-kind <kind>", "Verifier kind: shell|file_exists|thread_concluded|reviewer_agent").option("--verify-path <path>", "Path that must exist (--verify-kind file_exists)").option("--verify-thread <id>", "Thread that must be concluded (--verify-kind thread_concluded)").option("--verify-reviewer <agent>", "Agent (id or name) who must approve (--verify-kind reviewer_agent)").option("--review-by <agent>", "Shorthand for --verify-kind reviewer_agent --verify-reviewer <agent>").action(taskCreateCommand);
package/dist/mcp.js CHANGED
@@ -12847,14 +12847,19 @@ ${version.body}`
12847
12847
  },
12848
12848
  {
12849
12849
  name: "add_task_comment",
12850
- description: "Post a comment on a task's discussion thread. The task's thread is created lazily on first access \u2014 you don't need to create a thread first. Use this to record findings, review notes, or status updates directly on the task rather than in a separate thread. Prefer 'finding' or 'status' as the type for inline notes.",
12850
+ description: "Post a comment on a task's discussion thread. The task's thread is created lazily on first access \u2014 you don't need to create a thread first. Use this to record findings, review notes, or status updates directly on the task rather than in a separate thread. Prefer 'finding' or 'status' as the type for inline notes. When you report progress or hand work back, set toAgent to the agent you report to (the task's createdBy unless you were told otherwise) so the comment is addressed to them.",
12851
12851
  inputSchema: external_exports.object({
12852
12852
  taskId: external_exports.string().describe("The task to comment on."),
12853
12853
  body: external_exports.string().min(1).describe("Comment text."),
12854
- type: external_exports.enum(["status", "handoff", "finding", "decision", "question", "escalation", "reply"]).optional().describe("Message type. Defaults to 'status'.")
12854
+ type: external_exports.enum(["status", "handoff", "finding", "decision", "question", "escalation", "reply"]).optional().describe("Message type. Defaults to 'status'."),
12855
+ toAgent: external_exports.string().optional().describe("Agent ID to address: the agent you report to, usually the task's createdBy. Must be in the task's repo or be its creator.")
12855
12856
  }),
12856
12857
  handler: async (input) => {
12857
- const message = await client.addTaskComment(input.taskId, { body: input.body, type: input.type });
12858
+ const message = await client.addTaskComment(input.taskId, {
12859
+ body: input.body,
12860
+ type: input.type,
12861
+ ...input.toAgent ? { toAgent: input.toAgent } : {}
12862
+ });
12858
12863
  return { content: [{ type: "text", text: JSON.stringify(message, null, 2) }] };
12859
12864
  }
12860
12865
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitboss/cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Connect your coding agents to a pitboss instance",
6
6
  "keywords": [