@pitboss/cli 0.2.2 → 0.3.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.
- package/README.md +43 -107
- package/dist/cli.js +657 -97
- package/dist/mcp.js +8 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,142 +1,78 @@
|
|
|
1
1
|
# @pitboss/cli
|
|
2
2
|
|
|
3
|
-
Connect your coding agents to
|
|
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
|
|
6
|
+
npx @pitboss/cli join
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
npm install -g @getrelai/cli
|
|
28
|
-
```
|
|
26
|
+
**Restart the agent session after joining.** An agent loads its tools at startup.
|
|
29
27
|
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
relai login --invite inv_<code> --api <https://your-relai-host>
|
|
36
|
-
```
|
|
30
|
+
## Wake-ups
|
|
37
31
|
|
|
38
|
-
|
|
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
|
-
|
|
35
|
+
pitboss watch --once # block until the next event for this folder's agent, print it as one JSON line with a summary, exit 0
|
|
36
|
+
pitboss watch # stream events until Ctrl-C (for a human watching a terminal)
|
|
42
37
|
```
|
|
43
38
|
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
## Several identities on one machine
|
|
66
65
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
Config lives in `~/.config/pitboss/config.json` (or `$PITBOSS_CONFIG_DIR/config.json`).
|
|
98
73
|
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
2164
|
+
this._lifeCycleHooks[event].forEach((hook2) => {
|
|
2165
2165
|
result = this._chainOrCall(result, () => {
|
|
2166
|
-
return
|
|
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((
|
|
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
|
-
|
|
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
|
|
17284
|
+
let resolve4;
|
|
17289
17285
|
let reject;
|
|
17290
17286
|
const promise = new Promise((res, rej) => {
|
|
17291
|
-
|
|
17287
|
+
resolve4 = res;
|
|
17292
17288
|
reject = rej;
|
|
17293
17289
|
});
|
|
17294
|
-
return { promise, resolve:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 [
|
|
19077
|
+
return [join3(repo2, ".mcp.json")];
|
|
18922
19078
|
case "cursor":
|
|
18923
|
-
return [
|
|
19079
|
+
return [join3(repo2, ".cursor", "mcp.json")];
|
|
18924
19080
|
case "windsurf":
|
|
18925
|
-
return [
|
|
19081
|
+
return [join3(home, ".codeium", "windsurf", "mcp_config.json")];
|
|
18926
19082
|
case "gemini":
|
|
18927
|
-
return [
|
|
19083
|
+
return [join3(home, ".gemini", "settings.json")];
|
|
18928
19084
|
case "copilot":
|
|
18929
|
-
return [
|
|
19085
|
+
return [join3(home, ".config", "github-copilot", "mcp.json")];
|
|
18930
19086
|
case "gpt":
|
|
18931
|
-
return [
|
|
19087
|
+
return [join3(home, ".codex", "mcp.json")];
|
|
18932
19088
|
case "mcp":
|
|
18933
|
-
return [
|
|
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,19 @@ function mergeMcpServer(existing, name, entry) {
|
|
|
18960
19116
|
mcpServers: { ...servers, [name]: entry }
|
|
18961
19117
|
};
|
|
18962
19118
|
}
|
|
19119
|
+
function legacyPitbossEntry(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
|
+
return ours ? legacy : void 0;
|
|
19123
|
+
}
|
|
19124
|
+
function dropLegacyPitbossEntry(config) {
|
|
19125
|
+
if (!legacyPitbossEntry(config)) return config;
|
|
19126
|
+
const { relai: _dropped, ...rest } = config.mcpServers;
|
|
19127
|
+
return { ...config, mcpServers: rest };
|
|
19128
|
+
}
|
|
18963
19129
|
function allRuntimeTargets({ home, repo: repo2 }) {
|
|
18964
19130
|
const fromRuntimes = RUNTIMES.flatMap((w) => runtimeTargets(w, { home, repo: repo2 }));
|
|
18965
|
-
return [.../* @__PURE__ */ new Set([...fromRuntimes,
|
|
19131
|
+
return [.../* @__PURE__ */ new Set([...fromRuntimes, join3(home, ".claude.json")])];
|
|
18966
19132
|
}
|
|
18967
19133
|
function holdsRelaiToken(existing, token2) {
|
|
18968
19134
|
if (typeof token2 !== "string" || token2 === "") return false;
|
|
@@ -18976,18 +19142,20 @@ function holdsRelaiToken(existing, token2) {
|
|
|
18976
19142
|
if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
|
|
18977
19143
|
const servers = scope.mcpServers;
|
|
18978
19144
|
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return false;
|
|
18979
|
-
|
|
18980
|
-
|
|
18981
|
-
|
|
18982
|
-
|
|
18983
|
-
|
|
19145
|
+
return ["pitboss", "relai"].some((name) => {
|
|
19146
|
+
const server = servers[name];
|
|
19147
|
+
if (typeof server !== "object" || server === null || Array.isArray(server)) return false;
|
|
19148
|
+
const env2 = server.env;
|
|
19149
|
+
if (typeof env2 !== "object" || env2 === null || Array.isArray(env2)) return false;
|
|
19150
|
+
return env2.API_SECRET === token2;
|
|
19151
|
+
});
|
|
18984
19152
|
});
|
|
18985
19153
|
}
|
|
18986
19154
|
|
|
18987
19155
|
// src/commands/token.ts
|
|
18988
19156
|
function gitRoot(cwd) {
|
|
18989
19157
|
try {
|
|
18990
|
-
return
|
|
19158
|
+
return execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim() || cwd;
|
|
18991
19159
|
} catch {
|
|
18992
19160
|
return cwd;
|
|
18993
19161
|
}
|
|
@@ -18998,7 +19166,7 @@ function configsHoldingToken(oldToken) {
|
|
|
18998
19166
|
for (const target of allRuntimeTargets({ home: homedir2(), repo: repo2 })) {
|
|
18999
19167
|
if (target === configPath() || !existsSync2(target)) continue;
|
|
19000
19168
|
try {
|
|
19001
|
-
if (holdsRelaiToken(JSON.parse(
|
|
19169
|
+
if (holdsRelaiToken(JSON.parse(readFileSync4(target, "utf-8")), oldToken)) found.push(target);
|
|
19002
19170
|
} catch {
|
|
19003
19171
|
found.push(`${target} ${source_default.dim("(unreadable, check by hand)")}`);
|
|
19004
19172
|
}
|
|
@@ -19015,6 +19183,7 @@ async function tokenRotateCommand() {
|
|
|
19015
19183
|
let revoked;
|
|
19016
19184
|
try {
|
|
19017
19185
|
({ token: token2, tokenId, revoked } = await client.rotateToken(config.agentId));
|
|
19186
|
+
resetAgentFollowups(join4(configDir(), "wake"), config.agentId);
|
|
19018
19187
|
s.succeed(source_default.green("New token issued"));
|
|
19019
19188
|
} catch (err) {
|
|
19020
19189
|
s.fail(source_default.red("Rotate failed"));
|
|
@@ -19110,10 +19279,10 @@ ${live.length} live, ${rows.length - live.length} revoked.`);
|
|
|
19110
19279
|
import { resolve as resolvePath } from "node:path";
|
|
19111
19280
|
|
|
19112
19281
|
// ../../shared/git/src/index.ts
|
|
19113
|
-
import { execFileSync as
|
|
19282
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
19114
19283
|
function getGitRoot(cwd) {
|
|
19115
19284
|
try {
|
|
19116
|
-
const out =
|
|
19285
|
+
const out = execFileSync3("git", ["rev-parse", "--show-toplevel"], {
|
|
19117
19286
|
cwd,
|
|
19118
19287
|
stdio: ["ignore", "pipe", "ignore"],
|
|
19119
19288
|
encoding: "utf-8"
|
|
@@ -19125,7 +19294,7 @@ function getGitRoot(cwd) {
|
|
|
19125
19294
|
}
|
|
19126
19295
|
function getOriginUrl(cwd) {
|
|
19127
19296
|
try {
|
|
19128
|
-
const out =
|
|
19297
|
+
const out = execFileSync3("git", ["remote", "get-url", "origin"], {
|
|
19129
19298
|
cwd,
|
|
19130
19299
|
stdio: ["ignore", "pipe", "ignore"],
|
|
19131
19300
|
encoding: "utf-8"
|
|
@@ -19154,18 +19323,18 @@ function repoNameFromUrl(url) {
|
|
|
19154
19323
|
|
|
19155
19324
|
// src/lib/agents-state.ts
|
|
19156
19325
|
import { homedir as homedir3 } from "node:os";
|
|
19157
|
-
import { join as
|
|
19158
|
-
import { readFileSync as
|
|
19159
|
-
import { createHash } from "node:crypto";
|
|
19326
|
+
import { join as join5, resolve as resolve2, dirname as dirname2 } from "node:path";
|
|
19327
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, existsSync as existsSync3 } from "node:fs";
|
|
19328
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
19160
19329
|
var stateOverridden = () => Boolean(process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE);
|
|
19161
19330
|
function statePath() {
|
|
19162
|
-
return process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE ||
|
|
19331
|
+
return process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE || join5(homedir3(), ".config", "pitboss", "agents.json");
|
|
19163
19332
|
}
|
|
19164
19333
|
function readAgentsState() {
|
|
19165
19334
|
const p = readableFrom(statePath(), legacyHomePath("agents.json"), stateOverridden());
|
|
19166
19335
|
if (!existsSync3(p)) return { agents: [] };
|
|
19167
19336
|
try {
|
|
19168
|
-
const raw = JSON.parse(
|
|
19337
|
+
const raw = JSON.parse(readFileSync5(p, "utf-8"));
|
|
19169
19338
|
if (!raw || !Array.isArray(raw.agents)) return { agents: [] };
|
|
19170
19339
|
return raw;
|
|
19171
19340
|
} catch {
|
|
@@ -19175,12 +19344,12 @@ function readAgentsState() {
|
|
|
19175
19344
|
function writeAgentsState(state) {
|
|
19176
19345
|
const p = statePath();
|
|
19177
19346
|
const migrating = !existsSync3(p);
|
|
19178
|
-
|
|
19179
|
-
|
|
19347
|
+
mkdirSync3(dirname2(p), { recursive: true });
|
|
19348
|
+
writeFileSync4(p, JSON.stringify(state, null, 2));
|
|
19180
19349
|
if (migrating) retireLegacy(legacyHomePath("agents.json"), p, stateOverridden());
|
|
19181
19350
|
}
|
|
19182
19351
|
function hashToken(token2) {
|
|
19183
|
-
return
|
|
19352
|
+
return createHash2("sha256").update(token2).digest("hex").slice(0, 16);
|
|
19184
19353
|
}
|
|
19185
19354
|
var WorkingDirCollisionError = class extends Error {
|
|
19186
19355
|
constructor(existing, newAgentId, repoName) {
|
|
@@ -19197,9 +19366,9 @@ var WorkingDirCollisionError = class extends Error {
|
|
|
19197
19366
|
repoName;
|
|
19198
19367
|
};
|
|
19199
19368
|
function claimWorkingDir(claim) {
|
|
19200
|
-
const absDir =
|
|
19369
|
+
const absDir = resolve2(claim.workingDir);
|
|
19201
19370
|
const state = readAgentsState();
|
|
19202
|
-
const existing = state.agents.find((a) =>
|
|
19371
|
+
const existing = state.agents.find((a) => resolve2(a.workingDir) === absDir);
|
|
19203
19372
|
if (existing && existing.agentId !== claim.agentId) {
|
|
19204
19373
|
throw new WorkingDirCollisionError(existing, claim.agentId, "");
|
|
19205
19374
|
}
|
|
@@ -19349,8 +19518,8 @@ Already logged in as ${source_default.bold(existing.agentName)} (${existing.agen
|
|
|
19349
19518
|
process.exit(1);
|
|
19350
19519
|
}
|
|
19351
19520
|
if (repoUrl) {
|
|
19352
|
-
const
|
|
19353
|
-
if (!
|
|
19521
|
+
const gitRoot3 = getGitRoot(requestedDir);
|
|
19522
|
+
if (!gitRoot3) {
|
|
19354
19523
|
const repoName = repoNameFromUrl(repoUrl);
|
|
19355
19524
|
console.error(source_default.red(`Not in a git repo. Clone first:`));
|
|
19356
19525
|
console.error(` git clone ${repoUrl}`);
|
|
@@ -19358,7 +19527,7 @@ Already logged in as ${source_default.bold(existing.agentName)} (${existing.agen
|
|
|
19358
19527
|
console.error(` relai login \u2026`);
|
|
19359
19528
|
process.exit(1);
|
|
19360
19529
|
}
|
|
19361
|
-
const origin = getOriginUrl(
|
|
19530
|
+
const origin = getOriginUrl(gitRoot3);
|
|
19362
19531
|
if (origin && normalizeRepoUrl(origin) !== normalizeRepoUrl(repoUrl)) {
|
|
19363
19532
|
console.error(
|
|
19364
19533
|
source_default.red(`You're in ${origin}, but this agent is for ${repoUrl}.`)
|
|
@@ -19368,7 +19537,7 @@ Already logged in as ${source_default.bold(existing.agentName)} (${existing.agen
|
|
|
19368
19537
|
);
|
|
19369
19538
|
process.exit(1);
|
|
19370
19539
|
}
|
|
19371
|
-
workingDir =
|
|
19540
|
+
workingDir = gitRoot3;
|
|
19372
19541
|
}
|
|
19373
19542
|
try {
|
|
19374
19543
|
claimWorkingDir({
|
|
@@ -19432,10 +19601,9 @@ ${source_default.cyan(JSON.stringify({
|
|
|
19432
19601
|
}
|
|
19433
19602
|
|
|
19434
19603
|
// src/commands/join.ts
|
|
19435
|
-
import { execFileSync as
|
|
19436
|
-
import { existsSync as
|
|
19437
|
-
import {
|
|
19438
|
-
import { basename, dirname as dirname3, join as join4 } from "node:path";
|
|
19604
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
19605
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync8, appendFileSync, realpathSync as realpathSync2 } from "node:fs";
|
|
19606
|
+
import { basename, dirname as dirname5, join as join8 } from "node:path";
|
|
19439
19607
|
import { homedir as homedir4 } from "node:os";
|
|
19440
19608
|
|
|
19441
19609
|
// src/lib/mcp-entry.ts
|
|
@@ -19444,11 +19612,135 @@ var MCP_SERVER_ENTRY = {
|
|
|
19444
19612
|
args: ["-y", "@pitboss/cli", "mcp"]
|
|
19445
19613
|
};
|
|
19446
19614
|
|
|
19615
|
+
// src/lib/agent-files.ts
|
|
19616
|
+
var REFUSED_MARKER = "PITBOSS-CONFIG-REFUSED";
|
|
19617
|
+
var shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
19618
|
+
function watchCommandLine(cli, dir) {
|
|
19619
|
+
return `${cli} watch --once --dir ${shq(dir)}`;
|
|
19620
|
+
}
|
|
19621
|
+
function pinnedCli(version) {
|
|
19622
|
+
return `npx -y @pitboss/cli@${version}`;
|
|
19623
|
+
}
|
|
19624
|
+
var isOurs = (command) => /@pitboss\/cli\S* hook /.test(String(command ?? ""));
|
|
19625
|
+
function objectField(obj, key) {
|
|
19626
|
+
const v = obj[key];
|
|
19627
|
+
if (v === void 0) return {};
|
|
19628
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error(`existing "${key}" is not an object; refusing to overwrite it`);
|
|
19629
|
+
return v;
|
|
19630
|
+
}
|
|
19631
|
+
function base(existing) {
|
|
19632
|
+
if (existing === null) return {};
|
|
19633
|
+
if (typeof existing !== "object" || Array.isArray(existing)) throw new Error("existing settings are not a JSON object; refusing to overwrite them");
|
|
19634
|
+
return existing;
|
|
19635
|
+
}
|
|
19636
|
+
function claudeLocalSettings(existing, cli) {
|
|
19637
|
+
const settings = base(existing);
|
|
19638
|
+
const hooks = objectField(settings, "hooks");
|
|
19639
|
+
const servers = Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : [];
|
|
19640
|
+
const withHook = (event) => {
|
|
19641
|
+
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
19642
|
+
const kept = groups.filter((g) => !(g.hooks ?? []).some((h) => isOurs(h.command)));
|
|
19643
|
+
return [...kept, { hooks: [{ type: "command", command: `${cli} hook claude --event=${event}` }] }];
|
|
19644
|
+
};
|
|
19645
|
+
return {
|
|
19646
|
+
...settings,
|
|
19647
|
+
enabledMcpjsonServers: servers.includes("pitboss") ? servers : [...servers, "pitboss"],
|
|
19648
|
+
hooks: { ...hooks, SessionStart: withHook("SessionStart"), Stop: withHook("Stop") }
|
|
19649
|
+
};
|
|
19650
|
+
}
|
|
19651
|
+
function cursorHooks(existing, cli, dir) {
|
|
19652
|
+
const file = base(existing);
|
|
19653
|
+
const hooks = objectField(file, "hooks");
|
|
19654
|
+
const stop = Array.isArray(hooks.stop) ? hooks.stop : [];
|
|
19655
|
+
const command = `${cli} hook cursor-stop --dir ${shq(dir)}`;
|
|
19656
|
+
return {
|
|
19657
|
+
version: 1,
|
|
19658
|
+
...file,
|
|
19659
|
+
hooks: { ...hooks, stop: [...stop.filter((h) => !isOurs(h.command)), { command, loop_limit: null }] }
|
|
19660
|
+
};
|
|
19661
|
+
}
|
|
19662
|
+
function cursorRule(cli, dir) {
|
|
19663
|
+
const watch = watchCommandLine(cli, dir);
|
|
19664
|
+
return `---
|
|
19665
|
+
description: Pitboss wake-ups for this agent
|
|
19666
|
+
alwaysApply: true
|
|
19667
|
+
---
|
|
19668
|
+
|
|
19669
|
+
# Pitboss
|
|
19670
|
+
|
|
19671
|
+
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.
|
|
19672
|
+
|
|
19673
|
+
## Wake loop
|
|
19674
|
+
|
|
19675
|
+
Pitboss cannot push into a running agent. You wake when a background watcher exits.
|
|
19676
|
+
|
|
19677
|
+
1. Before ending a turn, start this as a **background** command:
|
|
19678
|
+
|
|
19679
|
+
\`${watch}\`
|
|
19680
|
+
|
|
19681
|
+
It exits only when a pitboss event arrives for you, and you are woken when it does.
|
|
19682
|
+
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.
|
|
19683
|
+
3. Start the watcher again in the background before ending that turn.
|
|
19684
|
+
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.
|
|
19685
|
+
5. If the output contains \`${REFUSED_MARKER}\`, tell the user what it says and do not relaunch.
|
|
19686
|
+
|
|
19687
|
+
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.
|
|
19688
|
+
`;
|
|
19689
|
+
}
|
|
19690
|
+
|
|
19691
|
+
// src/version.ts
|
|
19692
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
19693
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
19694
|
+
import { fileURLToPath } from "node:url";
|
|
19695
|
+
var CLI_VERSION = JSON.parse(
|
|
19696
|
+
readFileSync6(join6(dirname3(fileURLToPath(import.meta.url)), "../package.json"), "utf8")
|
|
19697
|
+
).version;
|
|
19698
|
+
|
|
19699
|
+
// src/lib/safe-write.ts
|
|
19700
|
+
import { existsSync as existsSync4, lstatSync as lstatSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync7, renameSync as renameSync3, writeFileSync as writeFileSync5 } from "node:fs";
|
|
19701
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
19702
|
+
import { dirname as dirname4, join as join7, sep } from "node:path";
|
|
19703
|
+
function guard(target, repoRoot) {
|
|
19704
|
+
if (!target.startsWith(repoRoot + "/")) return;
|
|
19705
|
+
let path3 = repoRoot;
|
|
19706
|
+
for (const part of target.slice(repoRoot.length + 1).split(sep)) {
|
|
19707
|
+
path3 = join7(path3, part);
|
|
19708
|
+
if (lstatSync2(path3, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
19709
|
+
throw new Error(`${path3} is a symlink; refusing to write through it`);
|
|
19710
|
+
}
|
|
19711
|
+
}
|
|
19712
|
+
}
|
|
19713
|
+
function writeAtomic2(target, body) {
|
|
19714
|
+
mkdirSync4(dirname4(target), { recursive: true });
|
|
19715
|
+
const tmp = `${target}.relai-${randomBytes3(8).toString("hex")}`;
|
|
19716
|
+
writeFileSync5(tmp, body, { mode: 384, flag: "wx" });
|
|
19717
|
+
renameSync3(tmp, target);
|
|
19718
|
+
}
|
|
19719
|
+
function writeRepoFile(target, body, repoRoot) {
|
|
19720
|
+
guard(target, repoRoot);
|
|
19721
|
+
writeAtomic2(target, body);
|
|
19722
|
+
}
|
|
19723
|
+
function writeMergedJson(target, merge, repoRoot) {
|
|
19724
|
+
guard(target, repoRoot);
|
|
19725
|
+
let existing = null;
|
|
19726
|
+
if (existsSync4(target)) {
|
|
19727
|
+
const raw = readFileSync7(target, "utf-8").trim();
|
|
19728
|
+
if (raw) {
|
|
19729
|
+
try {
|
|
19730
|
+
existing = JSON.parse(raw);
|
|
19731
|
+
} catch {
|
|
19732
|
+
throw new Error(`${target} is not valid JSON; fix or move it, then run join again`);
|
|
19733
|
+
}
|
|
19734
|
+
}
|
|
19735
|
+
}
|
|
19736
|
+
writeAtomic2(target, JSON.stringify(merge(existing), null, 2) + "\n");
|
|
19737
|
+
}
|
|
19738
|
+
|
|
19447
19739
|
// src/commands/join.ts
|
|
19448
19740
|
var DEFAULT_API = "https://api.pitboss.dev";
|
|
19449
19741
|
function git(args, cwd) {
|
|
19450
19742
|
try {
|
|
19451
|
-
return
|
|
19743
|
+
return execFileSync4("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
19452
19744
|
} catch {
|
|
19453
19745
|
return null;
|
|
19454
19746
|
}
|
|
@@ -19500,40 +19792,52 @@ async function handshake(api, repoId, team) {
|
|
|
19500
19792
|
}
|
|
19501
19793
|
return { attempted: team.length, delivered };
|
|
19502
19794
|
}
|
|
19503
|
-
function
|
|
19504
|
-
|
|
19505
|
-
if (
|
|
19506
|
-
|
|
19507
|
-
|
|
19508
|
-
|
|
19509
|
-
|
|
19510
|
-
|
|
19511
|
-
|
|
19795
|
+
function wakeFiles(workerType, root) {
|
|
19796
|
+
const cli = pinnedCli(CLI_VERSION);
|
|
19797
|
+
if (workerType === "claude") {
|
|
19798
|
+
return [{ path: join8(root, ".claude", "settings.local.json"), write: (p) => writeMergedJson(p, (e) => claudeLocalSettings(e, cli), root) }];
|
|
19799
|
+
}
|
|
19800
|
+
if (workerType === "cursor") {
|
|
19801
|
+
return [
|
|
19802
|
+
{ path: join8(root, ".cursor", "hooks.json"), write: (p) => writeMergedJson(p, (e) => cursorHooks(e, cli, root), root) },
|
|
19803
|
+
{
|
|
19804
|
+
path: join8(root, ".cursor", "rules", "pitboss.mdc"),
|
|
19805
|
+
write: (p) => {
|
|
19806
|
+
if (existsSync5(p) && !readFileSync8(p, "utf-8").includes("description: Pitboss wake-ups")) {
|
|
19807
|
+
throw new Error(`${p} exists and was not written by pitboss; leaving it alone`);
|
|
19808
|
+
}
|
|
19809
|
+
writeRepoFile(p, cursorRule(cli, root), root);
|
|
19810
|
+
}
|
|
19512
19811
|
}
|
|
19513
|
-
|
|
19812
|
+
];
|
|
19514
19813
|
}
|
|
19515
|
-
|
|
19516
|
-
|
|
19517
|
-
|
|
19518
|
-
|
|
19814
|
+
return [];
|
|
19815
|
+
}
|
|
19816
|
+
function trackedIn(root, rel) {
|
|
19817
|
+
const icase = git(["config", "--bool", "core.ignorecase"], root) === "true";
|
|
19818
|
+
return git(["ls-files", "--error-unmatch", "--", icase ? `:(icase)${rel}` : rel], root) !== null;
|
|
19519
19819
|
}
|
|
19520
19820
|
function isTracked(rawTarget) {
|
|
19521
|
-
const target =
|
|
19522
|
-
|
|
19821
|
+
const target = existsSync5(rawTarget) ? realpathSync2.native(rawTarget) : rawTarget;
|
|
19822
|
+
let dir = dirname5(target);
|
|
19823
|
+
while (!existsSync5(dir)) dir = dirname5(dir);
|
|
19523
19824
|
const root = git(["rev-parse", "--show-toplevel"], dir);
|
|
19524
19825
|
if (!root) return false;
|
|
19525
|
-
|
|
19826
|
+
const realRoot = realpathSync2.native(root);
|
|
19827
|
+
const realDir = realpathSync2.native(dir);
|
|
19828
|
+
const rel = (realDir + target.slice(dir.length)).slice(realRoot.length + 1);
|
|
19829
|
+
return trackedIn(root, rel);
|
|
19526
19830
|
}
|
|
19527
19831
|
function excludeIfUntracked(repoRoot, target) {
|
|
19528
19832
|
if (!target.startsWith(repoRoot + "/")) return;
|
|
19529
|
-
const rel = target.slice(repoRoot.length + 1);
|
|
19833
|
+
const rel = realpathSync2.native(target).slice(realpathSync2.native(repoRoot).length + 1);
|
|
19530
19834
|
if (git(["check-ignore", "-q", rel], repoRoot) !== null) return;
|
|
19531
|
-
if (
|
|
19835
|
+
if (trackedIn(repoRoot, rel)) return;
|
|
19532
19836
|
const dir = git(["rev-parse", "--git-common-dir"], repoRoot);
|
|
19533
19837
|
if (!dir) return;
|
|
19534
|
-
const exclude =
|
|
19535
|
-
|
|
19536
|
-
const body =
|
|
19838
|
+
const exclude = join8(dir.startsWith("/") ? dir : join8(repoRoot, dir), "info", "exclude");
|
|
19839
|
+
mkdirSync5(dirname5(exclude), { recursive: true });
|
|
19840
|
+
const body = existsSync5(exclude) ? readFileSync8(exclude, "utf-8") : "";
|
|
19537
19841
|
if (!body.split("\n").some((l) => l.trim() === rel)) appendFileSync(exclude, `
|
|
19538
19842
|
${rel}
|
|
19539
19843
|
`);
|
|
@@ -19612,6 +19916,8 @@ async function run(opts) {
|
|
|
19612
19916
|
const skipped = [];
|
|
19613
19917
|
const failed = [];
|
|
19614
19918
|
let wroteCliConfig = "";
|
|
19919
|
+
const wakeWarnings = [];
|
|
19920
|
+
const dropped = [];
|
|
19615
19921
|
for (const invite of invites) {
|
|
19616
19922
|
const accepted = await postJson(`${api}/auth/accept-invite`, {
|
|
19617
19923
|
code: invite.code,
|
|
@@ -19646,7 +19952,13 @@ async function run(opts) {
|
|
|
19646
19952
|
writeConfig({ apiUrl: api, apiToken: agent.token, agentId: agent.data.id, agentName: invite.name, repoId, specialization: invite.specialization ?? void 0 });
|
|
19647
19953
|
wroteCliConfig = invite.name;
|
|
19648
19954
|
} else {
|
|
19649
|
-
|
|
19955
|
+
let legacy;
|
|
19956
|
+
writeMergedJson(dest, (e) => {
|
|
19957
|
+
const merged = mergeMcpServer(e, "pitboss", { ...MCP_SERVER_ENTRY, env: env2 });
|
|
19958
|
+
legacy = legacyPitbossEntry(merged);
|
|
19959
|
+
return dropLegacyPitbossEntry(merged);
|
|
19960
|
+
}, root);
|
|
19961
|
+
if (legacy) dropped.push(`${dest} (agent ${typeof legacy.env?.AGENT_ID === "string" ? legacy.env.AGENT_ID : "unknown"})`);
|
|
19650
19962
|
}
|
|
19651
19963
|
written.push(dest);
|
|
19652
19964
|
excludeIfUntracked(root, dest);
|
|
@@ -19655,6 +19967,21 @@ async function run(opts) {
|
|
|
19655
19967
|
failed.push({ name: invite.name, id: agent.data.id, why: err instanceof Error ? err.message : String(err) });
|
|
19656
19968
|
continue;
|
|
19657
19969
|
}
|
|
19970
|
+
if (written.length) {
|
|
19971
|
+
for (const file of wakeFiles(invite.workerType, root)) {
|
|
19972
|
+
if (isTracked(file.path)) {
|
|
19973
|
+
skipped.push({ name: invite.name, target: file.path });
|
|
19974
|
+
continue;
|
|
19975
|
+
}
|
|
19976
|
+
try {
|
|
19977
|
+
file.write(file.path);
|
|
19978
|
+
written.push(file.path);
|
|
19979
|
+
excludeIfUntracked(root, file.path);
|
|
19980
|
+
} catch (err) {
|
|
19981
|
+
wakeWarnings.push(`${invite.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
19982
|
+
}
|
|
19983
|
+
}
|
|
19984
|
+
}
|
|
19658
19985
|
connected.push({ name: invite.name, workerType: invite.workerType, targets: written });
|
|
19659
19986
|
team.push({ name: invite.name, id: agent.data.id, token: agent.token });
|
|
19660
19987
|
const where = written.length ? "" : source_default.yellow(" no config written");
|
|
@@ -19669,10 +19996,18 @@ async function run(opts) {
|
|
|
19669
19996
|
for (const f of failed) console.log(source_default.red(` ${f.name} (${f.id}): ${f.why}`));
|
|
19670
19997
|
}
|
|
19671
19998
|
if (skipped.length) {
|
|
19672
|
-
console.log(source_default.red("\n Refused to write
|
|
19999
|
+
console.log(source_default.red("\n Refused to write into a file git tracks:"));
|
|
19673
20000
|
for (const s of skipped) console.log(source_default.red(` ${s.name} -> ${s.target.replace(home, "~")}`));
|
|
19674
20001
|
console.log(source_default.dim(" Untrack it (git rm --cached <file>) and run join again, or configure that agent by hand."));
|
|
19675
20002
|
}
|
|
20003
|
+
if (dropped.length) {
|
|
20004
|
+
console.log(source_default.yellow('\n Replaced an old "relai" server entry with "pitboss"; removed:'));
|
|
20005
|
+
for (const d of dropped) console.log(source_default.yellow(` ${d.replace(home, "~")}`));
|
|
20006
|
+
}
|
|
20007
|
+
if (wakeWarnings.length) {
|
|
20008
|
+
console.log(source_default.yellow("\n Wake-up hooks not installed, so these agents only see new work when prompted:"));
|
|
20009
|
+
for (const w of wakeWarnings) console.log(source_default.yellow(` ${w}`));
|
|
20010
|
+
}
|
|
19676
20011
|
const shook = await handshake(api, repoId, team);
|
|
19677
20012
|
if (shook.attempted > 0) {
|
|
19678
20013
|
const ok = shook.delivered === shook.attempted;
|
|
@@ -19705,6 +20040,67 @@ async function mcpCommand() {
|
|
|
19705
20040
|
await import(new URL("mcp.js", import.meta.url).href);
|
|
19706
20041
|
}
|
|
19707
20042
|
|
|
20043
|
+
// src/commands/watch.ts
|
|
20044
|
+
import { join as join10 } from "node:path";
|
|
20045
|
+
|
|
20046
|
+
// src/lib/agent-creds.ts
|
|
20047
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
20048
|
+
import { existsSync as existsSync6, readFileSync as readFileSync9 } from "node:fs";
|
|
20049
|
+
import { dirname as dirname6, join as join9, resolve as resolve3 } from "node:path";
|
|
20050
|
+
var SERVER_NAMES = ["pitboss", "relai"];
|
|
20051
|
+
var AGENT_ID = /^agent_[A-Za-z0-9_-]{1,64}$/;
|
|
20052
|
+
function validate2(raw, source) {
|
|
20053
|
+
const { apiUrl, token: token2, agentId } = raw;
|
|
20054
|
+
if (typeof apiUrl !== "string" || typeof token2 !== "string" || typeof agentId !== "string" || !token2) {
|
|
20055
|
+
return { ok: false, reason: `${source} is missing API_URL, API_SECRET or AGENT_ID` };
|
|
20056
|
+
}
|
|
20057
|
+
if (!/^https?:\/\/[^\s]+$/.test(apiUrl)) return { ok: false, reason: `${source}: API_URL must be an http(s) URL` };
|
|
20058
|
+
if (!AGENT_ID.test(agentId)) return { ok: false, reason: `${source}: AGENT_ID is not an agent id` };
|
|
20059
|
+
if (/[\s\p{Cc}]/u.test(token2)) return { ok: false, reason: `${source}: API_SECRET contains whitespace or control characters` };
|
|
20060
|
+
return { ok: true, creds: { apiUrl: apiUrl.replace(/\/+$/, ""), token: token2, agentId }, source };
|
|
20061
|
+
}
|
|
20062
|
+
function fromMcpFile(file) {
|
|
20063
|
+
if (!existsSync6(file)) return null;
|
|
20064
|
+
let parsed;
|
|
20065
|
+
try {
|
|
20066
|
+
parsed = JSON.parse(readFileSync9(file, "utf-8"));
|
|
20067
|
+
} catch {
|
|
20068
|
+
return null;
|
|
20069
|
+
}
|
|
20070
|
+
const servers = parsed?.mcpServers;
|
|
20071
|
+
if (!servers || typeof servers !== "object") return null;
|
|
20072
|
+
for (const name of SERVER_NAMES) {
|
|
20073
|
+
const env2 = servers[name]?.env;
|
|
20074
|
+
if (env2 && typeof env2 === "object") return env2;
|
|
20075
|
+
}
|
|
20076
|
+
return null;
|
|
20077
|
+
}
|
|
20078
|
+
function gitRoot2(dir) {
|
|
20079
|
+
try {
|
|
20080
|
+
return execFileSync5("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
20081
|
+
} catch {
|
|
20082
|
+
return null;
|
|
20083
|
+
}
|
|
20084
|
+
}
|
|
20085
|
+
function resolveAgentCreds({ dir, env: env2, cliConfig }) {
|
|
20086
|
+
if (env2.API_URL && env2.API_SECRET && env2.AGENT_ID) {
|
|
20087
|
+
return validate2({ apiUrl: env2.API_URL, token: env2.API_SECRET, agentId: env2.AGENT_ID }, "environment");
|
|
20088
|
+
}
|
|
20089
|
+
const start = resolve3(dir);
|
|
20090
|
+
const stop = gitRoot2(start) ?? start;
|
|
20091
|
+
for (let cur = start; ; cur = dirname6(cur)) {
|
|
20092
|
+
for (const file of [join9(cur, ".mcp.json"), join9(cur, ".cursor", "mcp.json")]) {
|
|
20093
|
+
const found = fromMcpFile(file);
|
|
20094
|
+
if (found) return validate2({ apiUrl: found.API_URL, token: found.API_SECRET, agentId: found.AGENT_ID }, file);
|
|
20095
|
+
}
|
|
20096
|
+
if (cur === stop || dirname6(cur) === cur) break;
|
|
20097
|
+
}
|
|
20098
|
+
if (cliConfig) {
|
|
20099
|
+
return validate2({ apiUrl: cliConfig.apiUrl, token: cliConfig.apiToken, agentId: cliConfig.agentId }, "cli-config");
|
|
20100
|
+
}
|
|
20101
|
+
return { ok: false, reason: `no pitboss agent configured for ${start}: run \`pitboss join\` there` };
|
|
20102
|
+
}
|
|
20103
|
+
|
|
19708
20104
|
// src/commands/watch.ts
|
|
19709
20105
|
function truncate(s, n) {
|
|
19710
20106
|
const clean = (s ?? "").replace(/\s+/g, " ").trim();
|
|
@@ -19723,13 +20119,13 @@ function parseSseFrame(frame) {
|
|
|
19723
20119
|
return null;
|
|
19724
20120
|
}
|
|
19725
20121
|
}
|
|
19726
|
-
function summarize(e) {
|
|
20122
|
+
function summarize(e, bodyChars = 80) {
|
|
19727
20123
|
const p = e.payload ?? {};
|
|
19728
20124
|
const taskTitle = p.task?.title ?? e.targetId;
|
|
19729
20125
|
const threadTitle = p.thread?.title ?? e.targetId;
|
|
19730
20126
|
switch (e.kind) {
|
|
19731
20127
|
case "message.posted":
|
|
19732
|
-
return `${p.message?.type ?? "message"} from ${p.message?.fromAgent ?? "?"}: ${truncate(p.message?.body ?? "",
|
|
20128
|
+
return `${p.message?.type ?? "message"} from ${p.message?.fromAgent ?? "?"}: ${truncate(p.message?.body ?? "", bodyChars)}`;
|
|
19733
20129
|
case "task.created":
|
|
19734
20130
|
return `new task assigned to you: "${taskTitle}"`;
|
|
19735
20131
|
case "task.updated":
|
|
@@ -19754,6 +20150,9 @@ function summarize(e) {
|
|
|
19754
20150
|
return `${e.targetType} ${e.targetId}`;
|
|
19755
20151
|
}
|
|
19756
20152
|
}
|
|
20153
|
+
function wakeLine(first, count) {
|
|
20154
|
+
return JSON.stringify({ ...first, summary: summarize(first, 200), ...count > 1 ? { more: count - 1 } : {} });
|
|
20155
|
+
}
|
|
19757
20156
|
var KIND_STYLE = {
|
|
19758
20157
|
"message.posted": source_default.cyan,
|
|
19759
20158
|
"task.created": source_default.green,
|
|
@@ -19783,15 +20182,75 @@ async function consumeEventStream(stream, onEvent) {
|
|
|
19783
20182
|
const { done, value } = await reader.read();
|
|
19784
20183
|
if (done) break;
|
|
19785
20184
|
buffer += decoder.decode(value, { stream: true });
|
|
19786
|
-
let
|
|
19787
|
-
while ((
|
|
19788
|
-
const event = parseSseFrame(buffer.slice(0,
|
|
19789
|
-
buffer = buffer.slice(
|
|
20185
|
+
let sep2;
|
|
20186
|
+
while ((sep2 = buffer.indexOf("\n\n")) !== -1) {
|
|
20187
|
+
const event = parseSseFrame(buffer.slice(0, sep2));
|
|
20188
|
+
buffer = buffer.slice(sep2 + 2);
|
|
19790
20189
|
if (event) onEvent(event);
|
|
19791
20190
|
}
|
|
19792
20191
|
}
|
|
19793
20192
|
}
|
|
20193
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20194
|
+
function printAndExit(line, code) {
|
|
20195
|
+
return new Promise(() => process.stdout.write(`${line}
|
|
20196
|
+
`, () => process.exit(code)));
|
|
20197
|
+
}
|
|
20198
|
+
var refuse = (reason) => printAndExit(`${REFUSED_MARKER} ${reason}`, 1);
|
|
20199
|
+
async function watchOnce(dir) {
|
|
20200
|
+
const found = resolveAgentCreds({ dir, env: process.env, cliConfig: readConfig() });
|
|
20201
|
+
if (!found.ok) return refuse(found.reason);
|
|
20202
|
+
const { apiUrl, token: token2, agentId } = found.creds;
|
|
20203
|
+
const auth = { Authorization: `Bearer ${token2}` };
|
|
20204
|
+
const state = join10(configDir(), "wake");
|
|
20205
|
+
const key = wakeKey(agentId, dir);
|
|
20206
|
+
process.on("exit", registerWatcher(state, key));
|
|
20207
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(130));
|
|
20208
|
+
let backoffMs = 1e3;
|
|
20209
|
+
let subscribed = false;
|
|
20210
|
+
for (; ; ) {
|
|
20211
|
+
try {
|
|
20212
|
+
const probe = await fetch(`${apiUrl}/agents/${agentId}`, { headers: auth });
|
|
20213
|
+
if (probe.status === 401 || probe.status === 403 || probe.status === 404) {
|
|
20214
|
+
return refuse(`the API answered ${probe.status} for ${agentId} (from ${found.source}): token revoked or agent deleted`);
|
|
20215
|
+
}
|
|
20216
|
+
if (!subscribed) {
|
|
20217
|
+
const sub = await fetch(`${apiUrl}/subscriptions`, {
|
|
20218
|
+
method: "POST",
|
|
20219
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
20220
|
+
body: JSON.stringify({ agentId, targetType: "agent", targetId: agentId })
|
|
20221
|
+
});
|
|
20222
|
+
subscribed = sub.ok;
|
|
20223
|
+
}
|
|
20224
|
+
const cursor = readCursor(state, key);
|
|
20225
|
+
const res = await fetch(`${apiUrl}/events`, {
|
|
20226
|
+
headers: { ...auth, Accept: "text/event-stream", ...cursor ? { "Last-Event-ID": cursor } : {} }
|
|
20227
|
+
});
|
|
20228
|
+
if (res.status === 401 || res.status === 403) return refuse(`the event stream answered ${res.status} for ${agentId}`);
|
|
20229
|
+
if (!res.ok || !res.body) throw new Error(`event stream returned ${res.status}`);
|
|
20230
|
+
resetFollowups(state, key);
|
|
20231
|
+
const connectedAt = Date.now();
|
|
20232
|
+
let wake;
|
|
20233
|
+
try {
|
|
20234
|
+
wake = await readWake(res.body);
|
|
20235
|
+
} finally {
|
|
20236
|
+
if (Date.now() - connectedAt > 1e4) backoffMs = 1e3;
|
|
20237
|
+
}
|
|
20238
|
+
if (wake.lastId) writeCursor(state, key, wake.lastId);
|
|
20239
|
+
if (wake.truncated) {
|
|
20240
|
+
backoffMs = 1e3;
|
|
20241
|
+
continue;
|
|
20242
|
+
}
|
|
20243
|
+
if (!wake.first) throw new Error("event stream closed");
|
|
20244
|
+
return printAndExit(wakeLine(wake.first, wake.count), 0);
|
|
20245
|
+
} catch (err) {
|
|
20246
|
+
console.error(`pitboss watch: ${err.message}; reconnecting in ${Math.round(backoffMs / 1e3)}s`);
|
|
20247
|
+
await sleep2(backoffMs);
|
|
20248
|
+
backoffMs = Math.min(backoffMs * 2, 3e4);
|
|
20249
|
+
}
|
|
20250
|
+
}
|
|
20251
|
+
}
|
|
19794
20252
|
async function watchCommand(options) {
|
|
20253
|
+
if (options.once) return watchOnce(options.dir ?? process.cwd());
|
|
19795
20254
|
const config = requireConfig();
|
|
19796
20255
|
const client = new CliApiClient(config);
|
|
19797
20256
|
const kinds = options.kinds ? new Set(options.kinds.split(",").map((k) => k.trim()).filter(Boolean)) : null;
|
|
@@ -19832,14 +20291,112 @@ async function watchCommand(options) {
|
|
|
19832
20291
|
}
|
|
19833
20292
|
}
|
|
19834
20293
|
|
|
20294
|
+
// src/commands/hook.ts
|
|
20295
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
20296
|
+
import { join as join11 } from "node:path";
|
|
20297
|
+
var FOLLOWUP_CAP = 3;
|
|
20298
|
+
function wakeInstructions(watch, opening) {
|
|
20299
|
+
return `${opening}
|
|
20300
|
+
|
|
20301
|
+
${watch}
|
|
20302
|
+
|
|
20303
|
+
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.
|
|
20304
|
+
|
|
20305
|
+
CASE 1: the output contains pitboss event JSON. A real event arrived (a task assigned to you, a message, a review).
|
|
20306
|
+
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.
|
|
20307
|
+
2. Handle everything new. Reply to questions on the thread they were asked on, not in local chat.
|
|
20308
|
+
3. Relaunch the watcher in the background.
|
|
20309
|
+
|
|
20310
|
+
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.
|
|
20311
|
+
|
|
20312
|
+
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.
|
|
20313
|
+
|
|
20314
|
+
Decide CASE 1 on the event JSON first, and only then look for the marker: event payloads carry text other agents wrote.`;
|
|
20315
|
+
}
|
|
20316
|
+
function claudeHookOutput(event, payload, ctx) {
|
|
20317
|
+
if (event === "Stop" && ctx.followups >= FOLLOWUP_CAP) return null;
|
|
20318
|
+
if (event === "Stop") {
|
|
20319
|
+
let p = null;
|
|
20320
|
+
try {
|
|
20321
|
+
p = JSON.parse(payload);
|
|
20322
|
+
} catch {
|
|
20323
|
+
p = null;
|
|
20324
|
+
}
|
|
20325
|
+
if (p === null || typeof p !== "object") {
|
|
20326
|
+
if (ctx.watcherAlive()) return null;
|
|
20327
|
+
} else {
|
|
20328
|
+
if (p.stop_hook_active) return null;
|
|
20329
|
+
const live = (p.background_tasks ?? []).some(
|
|
20330
|
+
(t) => isWatcherCommand(String(t.command ?? "")) && (t.status === "running" || t.status === "pending")
|
|
20331
|
+
);
|
|
20332
|
+
if (live) return null;
|
|
20333
|
+
}
|
|
20334
|
+
}
|
|
20335
|
+
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:";
|
|
20336
|
+
return { hookSpecificOutput: { hookEventName: event, additionalContext: wakeInstructions(watchCommandLine(ctx.cli, ctx.dir), opening) } };
|
|
20337
|
+
}
|
|
20338
|
+
function cursorStopOutput(payload, ctx) {
|
|
20339
|
+
let status;
|
|
20340
|
+
try {
|
|
20341
|
+
status = JSON.parse(payload).status;
|
|
20342
|
+
} catch {
|
|
20343
|
+
return {};
|
|
20344
|
+
}
|
|
20345
|
+
if (status !== "completed" || ctx.followups >= FOLLOWUP_CAP || ctx.watcherAlive()) return {};
|
|
20346
|
+
return {
|
|
20347
|
+
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.`
|
|
20348
|
+
};
|
|
20349
|
+
}
|
|
20350
|
+
function readStdin() {
|
|
20351
|
+
if (process.stdin.isTTY) return "";
|
|
20352
|
+
try {
|
|
20353
|
+
return readFileSync10(0, "utf-8");
|
|
20354
|
+
} catch {
|
|
20355
|
+
return "";
|
|
20356
|
+
}
|
|
20357
|
+
}
|
|
20358
|
+
function wakeStateFor(dir) {
|
|
20359
|
+
const found = resolveAgentCreds({ dir, env: {}, cliConfig: null });
|
|
20360
|
+
if (!found.ok) return null;
|
|
20361
|
+
const state = join11(configDir(), "wake");
|
|
20362
|
+
const key = wakeKey(found.creds.agentId, dir);
|
|
20363
|
+
return {
|
|
20364
|
+
watcherAlive: () => watcherAlive(state, key),
|
|
20365
|
+
followups: followupCount(state, key),
|
|
20366
|
+
asked: () => recordFollowup(state, key)
|
|
20367
|
+
};
|
|
20368
|
+
}
|
|
20369
|
+
function hookClaudeCommand(opts) {
|
|
20370
|
+
const event = opts.event === "Stop" ? "Stop" : "SessionStart";
|
|
20371
|
+
const payload = readStdin();
|
|
20372
|
+
let cwd;
|
|
20373
|
+
try {
|
|
20374
|
+
cwd = JSON.parse(payload).cwd;
|
|
20375
|
+
} catch {
|
|
20376
|
+
cwd = void 0;
|
|
20377
|
+
}
|
|
20378
|
+
const dir = process.env.CLAUDE_PROJECT_DIR || typeof cwd === "string" && cwd || process.cwd();
|
|
20379
|
+
const wake = wakeStateFor(dir);
|
|
20380
|
+
if (!wake) return;
|
|
20381
|
+
const out = claudeHookOutput(event, payload, { cli: pinnedCli(CLI_VERSION), dir, ...wake });
|
|
20382
|
+
if (out && event === "Stop") wake.asked();
|
|
20383
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
20384
|
+
}
|
|
20385
|
+
function hookCursorStopCommand(opts) {
|
|
20386
|
+
const dir = opts.dir ?? process.cwd();
|
|
20387
|
+
const wake = wakeStateFor(dir);
|
|
20388
|
+
if (!wake) {
|
|
20389
|
+
process.stdout.write("{}");
|
|
20390
|
+
return;
|
|
20391
|
+
}
|
|
20392
|
+
const out = cursorStopOutput(readStdin(), { cli: pinnedCli(CLI_VERSION), dir, ...wake });
|
|
20393
|
+
if ("followup_message" in out) wake.asked();
|
|
20394
|
+
process.stdout.write(JSON.stringify(out));
|
|
20395
|
+
}
|
|
20396
|
+
|
|
19835
20397
|
// 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
20398
|
var program2 = new Command();
|
|
19842
|
-
program2.name("pitboss").description("Coordinate your coding agents from the terminal").version(
|
|
20399
|
+
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
20400
|
if (thisCommand.opts().input === false) process.env.RELAI_NO_INPUT = "1";
|
|
19844
20401
|
});
|
|
19845
20402
|
program2.command("mcp", { hidden: true }).description("Run the MCP server on stdio (used by agent config, not by hand)").allowUnknownOption().action(mcpCommand);
|
|
@@ -19847,7 +20404,10 @@ program2.command("join").description("Connect this machine's agents: opens a cod
|
|
|
19847
20404
|
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
20405
|
program2.command("status").description("Show your agent info, online agents, task summary, and unread count").action(statusCommand);
|
|
19849
20406
|
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);
|
|
20407
|
+
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);
|
|
20408
|
+
var hook = program2.command("hook", { hidden: true }).description("Agent runtime hooks that keep the watcher running (installed by join)");
|
|
20409
|
+
hook.command("claude").description("Claude Code SessionStart/Stop hook").option("--event <event>", "SessionStart | Stop", "SessionStart").action(hookClaudeCommand);
|
|
20410
|
+
hook.command("cursor-stop").description("Cursor stop hook").option("--dir <path>", "The agent's folder").action(hookCursorStopCommand);
|
|
19851
20411
|
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
20412
|
var task = program2.command("task").description("Create or update a task");
|
|
19853
20413
|
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, {
|
|
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
|
},
|