@pitboss/cli 0.1.3 → 0.2.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 CHANGED
@@ -38,7 +38,7 @@ relai login --invite inv_<code> --api <https://your-relai-host>
38
38
  If you're standing one up yourself, see the [main repo](https://github.com/phillipsio/relai) for setup; then:
39
39
 
40
40
  ```bash
41
- relai init # guided setup — saves config to ~/.config/relai/config.json
41
+ relai init # guided setup — saves config to ~/.config/pitboss/config.json
42
42
  ```
43
43
 
44
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.
@@ -117,16 +117,16 @@ relai token revoke <tokenId> Revoke a specific token
117
117
 
118
118
  ## Multi-identity
119
119
 
120
- Set `RELAI_CONFIG_DIR` to override the config location and run multiple agent identities side by side from one machine:
120
+ Set `PITBOSS_CONFIG_DIR` to override the config location and run multiple agent identities side by side from one machine:
121
121
 
122
122
  ```bash
123
- export RELAI_CONFIG_DIR=/tmp/relai-coworker
123
+ export PITBOSS_CONFIG_DIR=/tmp/relai-coworker
124
124
  relai login --invite inv_<...>
125
125
  ```
126
126
 
127
127
  ## Config
128
128
 
129
- `~/.config/relai/config.json` (or `$RELAI_CONFIG_DIR/config.json`):
129
+ `~/.config/pitboss/config.json` (or `$PITBOSS_CONFIG_DIR/config.json`):
130
130
 
131
131
  ```json
132
132
  {
package/dist/cli.js CHANGED
@@ -18010,14 +18010,46 @@ var dist_default7 = createPrompt((config, done) => {
18010
18010
  // src/config.ts
18011
18011
  import { homedir } from "node:os";
18012
18012
  import { join, dirname } from "node:path";
18013
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync, existsSync, renameSync } from "node:fs";
18013
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync, existsSync, renameSync, rmSync as rmSync2, lstatSync, statSync } from "node:fs";
18014
18014
  import { randomBytes } from "node:crypto";
18015
+ var configDirOverride = () => process.env.PITBOSS_CONFIG_DIR || process.env.RELAI_CONFIG_DIR || void 0;
18016
+ function configDir() {
18017
+ return configDirOverride() ?? join(homedir(), ".config", "pitboss");
18018
+ }
18015
18019
  function configPath() {
18016
- const dir = process.env.RELAI_CONFIG_DIR ?? join(homedir(), ".config", "relai");
18017
- return join(dir, "config.json");
18020
+ return join(configDir(), "config.json");
18021
+ }
18022
+ function legacyHomePath(name) {
18023
+ return join(homedir(), ".config", "relai", name);
18024
+ }
18025
+ var configOverridden = () => configDirOverride() !== void 0;
18026
+ var warnedFor = /* @__PURE__ */ new Set();
18027
+ function readableFrom(current, legacy, overridden) {
18028
+ if (overridden || existsSync(current) || !existsSync(legacy)) return current;
18029
+ if (!warnedFor.has(legacy)) {
18030
+ warnedFor.add(legacy);
18031
+ console.error(`pitboss: reading ${legacy}; the next write moves it to ${current}.`);
18032
+ }
18033
+ return legacy;
18034
+ }
18035
+ function retireLegacy(legacy, current, overridden) {
18036
+ if (overridden) return;
18037
+ try {
18038
+ const found = lstatSync(legacy, { throwIfNoEntry: false });
18039
+ if (!found) return;
18040
+ const [a, b] = [statSync(dirname(legacy)), statSync(dirname(current))];
18041
+ if (a.dev === b.dev && a.ino === b.ino) return;
18042
+ rmSync2(legacy);
18043
+ } catch (err) {
18044
+ if (err.code === "ENOENT") return;
18045
+ console.error(`pitboss: could not remove ${legacy} (${String(err)}); it may still hold a token, delete it by hand.`);
18046
+ }
18047
+ }
18048
+ function configFileInUse() {
18049
+ return readableFrom(configPath(), legacyHomePath("config.json"), configOverridden());
18018
18050
  }
18019
18051
  function readConfig() {
18020
- const file = configPath();
18052
+ const file = configFileInUse();
18021
18053
  if (!existsSync(file)) return null;
18022
18054
  try {
18023
18055
  const raw = JSON.parse(readFileSync2(file, "utf-8"));
@@ -18029,10 +18061,12 @@ function readConfig() {
18029
18061
  }
18030
18062
  function writeConfig(config) {
18031
18063
  const file = configPath();
18064
+ const migrating = !existsSync(file);
18032
18065
  mkdirSync(dirname(file), { recursive: true });
18033
18066
  const tmp = `${file}.relai-${randomBytes(8).toString("hex")}`;
18034
18067
  writeFileSync2(tmp, JSON.stringify(config, null, 2), { mode: 384, flag: "wx" });
18035
18068
  renameSync(tmp, file);
18069
+ if (migrating) retireLegacy(legacyHomePath("config.json"), file, configOverridden());
18036
18070
  return file;
18037
18071
  }
18038
18072
  function requireConfig() {
@@ -18896,7 +18930,7 @@ function runtimeTargets(workerType, { home, repo: repo2 }) {
18896
18930
  case "gpt":
18897
18931
  return [join2(home, ".codex", "mcp.json")];
18898
18932
  case "mcp":
18899
- return [join2(home, ".config", "relai", "config.json")];
18933
+ return [join2(home, ".config", "pitboss", "config.json")];
18900
18934
  // A person, not a runtime. Nothing to configure.
18901
18935
  case "human":
18902
18936
  return [];
@@ -19123,12 +19157,12 @@ import { homedir as homedir3 } from "node:os";
19123
19157
  import { join as join3, resolve, dirname as dirname2 } from "node:path";
19124
19158
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "node:fs";
19125
19159
  import { createHash } from "node:crypto";
19160
+ var stateOverridden = () => Boolean(process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE);
19126
19161
  function statePath() {
19127
- if (process.env.RELAI_AGENTS_STATE) return process.env.RELAI_AGENTS_STATE;
19128
- return join3(homedir3(), ".config", "relai", "agents.json");
19162
+ return process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE || join3(homedir3(), ".config", "pitboss", "agents.json");
19129
19163
  }
19130
19164
  function readAgentsState() {
19131
- const p = statePath();
19165
+ const p = readableFrom(statePath(), legacyHomePath("agents.json"), stateOverridden());
19132
19166
  if (!existsSync3(p)) return { agents: [] };
19133
19167
  try {
19134
19168
  const raw = JSON.parse(readFileSync4(p, "utf-8"));
@@ -19140,8 +19174,10 @@ function readAgentsState() {
19140
19174
  }
19141
19175
  function writeAgentsState(state) {
19142
19176
  const p = statePath();
19177
+ const migrating = !existsSync3(p);
19143
19178
  mkdirSync2(dirname2(p), { recursive: true });
19144
19179
  writeFileSync3(p, JSON.stringify(state, null, 2));
19180
+ if (migrating) retireLegacy(legacyHomePath("agents.json"), p, stateOverridden());
19145
19181
  }
19146
19182
  function hashToken(token2) {
19147
19183
  return createHash("sha256").update(token2).digest("hex").slice(0, 16);
@@ -19242,10 +19278,12 @@ async function loginCommand(opts) {
19242
19278
  if (existing && opts.invite) {
19243
19279
  console.log(source_default.yellow(`
19244
19280
  Already logged in as ${source_default.bold(existing.agentName)} (${existing.agentId})`));
19245
- console.log(source_default.dim("Delete ~/.config/relai/config.json to re-login.\n"));
19281
+ console.log(source_default.dim(`Delete ${configFileInUse()} to re-login.
19282
+ `));
19246
19283
  return;
19247
19284
  }
19248
- const apiUrl = opts.api ?? await dist_default6({ message: "API URL", default: "http://localhost:3010" });
19285
+ const ni = nonInteractive();
19286
+ const apiUrl = opts.api || (ni ? requireFlag("api url", "--api <url>") : await dist_default6({ message: "API URL", default: "http://localhost:3010" }));
19249
19287
  let agentId;
19250
19288
  let agentName;
19251
19289
  let repoId;
@@ -19269,14 +19307,14 @@ Already logged in as ${source_default.bold(existing.agentName)} (${existing.agen
19269
19307
  }
19270
19308
  } else {
19271
19309
  const client = new CliApiClient({ apiUrl });
19272
- const name = await dist_default6({
19310
+ const name = opts.name || (ni ? void 0 : await dist_default6({
19273
19311
  message: "Agent name",
19274
19312
  default: `${process.env.USER ?? "agent"}-claude-code`
19275
- });
19276
- const specialization = await dist_default7({
19313
+ }));
19314
+ const specialization = opts.specialization || (ni ? void 0 : await dist_default7({
19277
19315
  message: "Specialization",
19278
19316
  choices: SPECIALIZATION_CHOICES
19279
- });
19317
+ }));
19280
19318
  const specForApi = specialization === "custom" ? void 0 : specialization;
19281
19319
  const s = ora("Accepting invite\u2026").start();
19282
19320
  try {
@@ -19289,7 +19327,7 @@ Already logged in as ${source_default.bold(existing.agentName)} (${existing.agen
19289
19327
  agentId = result.agent.id;
19290
19328
  agentName = result.agent.name;
19291
19329
  repoId = result.agent.repoId;
19292
- agentSpecialization = specialization === "custom" ? void 0 : specialization;
19330
+ agentSpecialization = result.agent.specialization ?? void 0;
19293
19331
  token2 = result.token;
19294
19332
  s.succeed(source_default.green("Logged in"));
19295
19333
  } catch (err) {
@@ -19581,7 +19619,8 @@ async function run(opts) {
19581
19619
  role: invite.role,
19582
19620
  specialization: invite.specialization ?? void 0,
19583
19621
  workerType: invite.workerType,
19584
- domains: invite.domains ?? []
19622
+ domains: invite.domains ?? [],
19623
+ .../^[^\p{Cc}\p{Cf}\u2028\u2029\u0085]{1,1024}$/u.test(root) ? { repoPath: root } : {}
19585
19624
  });
19586
19625
  if (accepted.status !== 201) {
19587
19626
  const said = accepted.payload?.error?.message;
@@ -19827,7 +19866,7 @@ program2.command("send <threadId>").description("Send a message to a thread").op
19827
19866
  program2.command("inbox").description("Show unread messages").option("-r, --read", "Mark all displayed messages as read").action(inboxCommand);
19828
19867
  program2.command("repos").description("List all repos on this server").action(reposListCommand);
19829
19868
  program2.command("agents").description("List agents in the current repo").action(agentsListCommand);
19830
- program2.command("login").description("Log in to a repo on this machine using an invite code or an agent token").option("--invite <code>", "Invite code from `pitboss repo invite`").option("--token <token>", "Per-agent token (from the cloud dashboard)").option("--api <url>", "API URL (skips prompt)").option("--working-dir <path>", "Override the working directory (defaults to CWD)").option("--worker-type <type>", "Agent runtime: claude, copilot, cursor, windsurf, gemini, gpt, mcp, human (default: human)").action(loginCommand);
19869
+ program2.command("login").description("Log in to a repo on this machine using an invite code or an agent token").option("--invite <code>", "Invite code from `pitboss repo invite`").option("--token <token>", "Per-agent token (from the cloud dashboard)").option("--api <url>", "API URL (skips prompt)").option("--working-dir <path>", "Override the working directory (defaults to CWD)").option("--worker-type <type>", "Agent runtime: claude, copilot, cursor, windsurf, gemini, gpt, mcp, human (default: human)").option("--name <name>", "Agent name (defaults to the invite's suggestion when not interactive)").option("--specialization <spec>", "Agent specialization: writer, reviewer, tester, architect, devops, orchestrator (defaults to the invite's suggestion)").action(loginCommand);
19831
19870
  var repo = program2.command("repo").description("Repo operations");
19832
19871
  repo.command("show [id]").description("Show a repo's details (defaults to the current repo)").action(repoShowCommand);
19833
19872
  var repoContext = repo.command("context").description("View or edit the repo's pinned context (read by every agent on session start)");
package/dist/mcp.js CHANGED
@@ -9766,10 +9766,6 @@ var require_content_type = __commonJS({
9766
9766
  });
9767
9767
 
9768
9768
  // ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.6.0/node_modules/@modelcontextprotocol/sdk/dist/esm/server/sse.js
9769
- var sse_exports = {};
9770
- __export(sse_exports, {
9771
- SSEServerTransport: () => SSEServerTransport
9772
- });
9773
9769
  import { randomUUID } from "node:crypto";
9774
9770
  var import_raw_body, import_content_type, MAXIMUM_MESSAGE_SIZE, SSEServerTransport;
9775
9771
  var init_sse = __esm({
@@ -9888,6 +9884,106 @@ data: ${JSON.stringify(message)}
9888
9884
  }
9889
9885
  });
9890
9886
 
9887
+ // ../mcp-server/src/http-auth.ts
9888
+ var http_auth_exports = {};
9889
+ __export(http_auth_exports, {
9890
+ isAuthorizedBearer: () => isAuthorizedBearer,
9891
+ isBlankCredential: () => isBlankCredential,
9892
+ resolveHttpCredential: () => resolveHttpCredential
9893
+ });
9894
+ import { createHash, timingSafeEqual } from "node:crypto";
9895
+ function sha256(value) {
9896
+ return createHash("sha256").update(value, "utf8").digest();
9897
+ }
9898
+ function isBlankCredential(value) {
9899
+ return value.trim().length === 0;
9900
+ }
9901
+ function isAuthorizedBearer(authHeader, credential) {
9902
+ if (isBlankCredential(credential) || !authHeader) return false;
9903
+ const spaceIndex = authHeader.indexOf(" ");
9904
+ if (spaceIndex === -1) return false;
9905
+ if (authHeader.slice(0, spaceIndex).toLowerCase() !== "bearer") return false;
9906
+ const token = authHeader.slice(spaceIndex + 1);
9907
+ return timingSafeEqual(sha256(token), sha256(credential));
9908
+ }
9909
+ function resolveHttpCredential(mcpHttpToken, ownerMode, ownerToken, apiSecret) {
9910
+ if (mcpHttpToken) return mcpHttpToken;
9911
+ return (ownerMode ? ownerToken : apiSecret) ?? "";
9912
+ }
9913
+ var init_http_auth = __esm({
9914
+ "../mcp-server/src/http-auth.ts"() {
9915
+ "use strict";
9916
+ }
9917
+ });
9918
+
9919
+ // ../mcp-server/src/http-transport.ts
9920
+ var http_transport_exports = {};
9921
+ __export(http_transport_exports, {
9922
+ createHttpRequestListener: () => createHttpRequestListener
9923
+ });
9924
+ function createHttpRequestListener(server2, credential) {
9925
+ let current;
9926
+ let takeoverQueue = Promise.resolve();
9927
+ function detach(transport) {
9928
+ transport.onclose = void 0;
9929
+ transport.onerror = void 0;
9930
+ transport.onmessage = void 0;
9931
+ }
9932
+ async function takeOver(res) {
9933
+ const replaced = current;
9934
+ current = void 0;
9935
+ if (replaced) {
9936
+ detach(replaced);
9937
+ try {
9938
+ await replaced.close();
9939
+ } catch {
9940
+ }
9941
+ }
9942
+ const transport = new SSEServerTransport("/messages", res);
9943
+ try {
9944
+ await server2.connect(transport);
9945
+ current = transport;
9946
+ } catch (err) {
9947
+ detach(transport);
9948
+ console.error("[relai-mcp] failed to establish SSE connection:", err instanceof Error ? err.message : err);
9949
+ if (!res.headersSent) {
9950
+ res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { code: "connect_failed", message: "Failed to establish SSE connection" } }));
9951
+ }
9952
+ }
9953
+ }
9954
+ return async (req, res) => {
9955
+ if (!isAuthorizedBearer(req.headers.authorization, credential)) {
9956
+ console.error(`[relai-mcp] rejected unauthenticated HTTP request: ${req.method} ${req.url}`);
9957
+ res.writeHead(401, { "content-type": "application/json", "www-authenticate": "Bearer" }).end(UNAUTHORIZED_BODY);
9958
+ return;
9959
+ }
9960
+ const path = (req.url ?? "").split("?")[0];
9961
+ if (req.method === "GET" && path === "/sse") {
9962
+ const attempt = takeoverQueue.then(() => takeOver(res), () => takeOver(res));
9963
+ takeoverQueue = attempt;
9964
+ await attempt;
9965
+ } else if (req.method === "POST" && path === "/messages") {
9966
+ res.writeHead(501, { "content-type": "application/json" }).end(NOT_IMPLEMENTED_BODY);
9967
+ } else {
9968
+ res.writeHead(404).end();
9969
+ }
9970
+ };
9971
+ }
9972
+ var UNAUTHORIZED_BODY, NOT_IMPLEMENTED_BODY;
9973
+ var init_http_transport = __esm({
9974
+ "../mcp-server/src/http-transport.ts"() {
9975
+ "use strict";
9976
+ init_sse();
9977
+ init_http_auth();
9978
+ UNAUTHORIZED_BODY = JSON.stringify({
9979
+ error: { code: "unauthorized", message: "Missing or invalid bearer token" }
9980
+ });
9981
+ NOT_IMPLEMENTED_BODY = JSON.stringify({
9982
+ error: { code: "not_implemented", message: "This server does not yet process incoming tool-call messages" }
9983
+ });
9984
+ }
9985
+ });
9986
+
9891
9987
  // ../mcp-server/src/index.ts
9892
9988
  import { readFileSync } from "node:fs";
9893
9989
  import { join } from "node:path";
@@ -12177,6 +12273,12 @@ var ApiClient = class {
12177
12273
  `/agents/${agentId}/tokens`
12178
12274
  );
12179
12275
  }
12276
+ deleteAgent(agentId) {
12277
+ return this.request("DELETE", `/agents/${agentId}`);
12278
+ }
12279
+ revokeToken(tokenId) {
12280
+ return this.request("DELETE", `/tokens/${tokenId}`);
12281
+ }
12180
12282
  listInvites(repoId) {
12181
12283
  return this.request(
12182
12284
  "GET",
@@ -12824,7 +12926,39 @@ ${version.body}`
12824
12926
  // Additive, never a swap: an owner-scoped agent is still an agent and needs
12825
12927
  // everything above. buildOperatorTools assumes no agent identity, and its
12826
12928
  // create_task bypasses propose/commit.
12827
- ...opts.ownerScoped ? buildProvisioningTools(client) : []
12929
+ ...opts.ownerScoped ? [...buildProvisioningTools(client, { credentialMinting: true }), ...buildAgentManagementTools(client, agentId)] : []
12930
+ ];
12931
+ }
12932
+ function buildAgentManagementTools(client, selfId) {
12933
+ const text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
12934
+ return [
12935
+ {
12936
+ name: "remove_agent",
12937
+ description: "Permanently delete one of the owner's agents, with its tokens. Cannot be undone: the agent has to be invited again to come back. Use list_agents to find the id. You cannot remove yourself, and a repo's only orchestrator cannot be removed.",
12938
+ inputSchema: external_exports.object({ agentId: external_exports.string().min(1).describe("The agent to delete.") }),
12939
+ handler: async (input) => {
12940
+ if (input.agentId === selfId) {
12941
+ return { content: [{ type: "text", text: "Refused: you cannot remove yourself." }], isError: true };
12942
+ }
12943
+ await client.deleteAgent(input.agentId);
12944
+ return text({ removed: input.agentId });
12945
+ }
12946
+ },
12947
+ {
12948
+ name: "list_tokens",
12949
+ description: "List an agent's tokens: id, createdAt, lastUsedAt, revokedAt, whether each is owner-scoped, and which one is current. Never the token itself. Use it before revoke_token.",
12950
+ inputSchema: external_exports.object({ agentId: external_exports.string().min(1).describe("Whose tokens to list.") }),
12951
+ handler: async (input) => text({ tokens: await client.listAgentTokens(input.agentId) })
12952
+ },
12953
+ {
12954
+ name: "revoke_token",
12955
+ description: "Revoke one token by id, so whatever holds it stops working at once. The agent itself stays; it needs a new token or invite to reconnect. Revoking your own current token ends this session.",
12956
+ inputSchema: external_exports.object({ tokenId: external_exports.string().min(1).describe("From list_tokens.") }),
12957
+ handler: async (input) => {
12958
+ await client.revokeToken(input.tokenId);
12959
+ return text({ revoked: input.tokenId });
12960
+ }
12961
+ }
12828
12962
  ];
12829
12963
  }
12830
12964
  function selectTools(client, config) {
@@ -12879,11 +13013,7 @@ function buildProvisioningTools(client, opts = {}) {
12879
13013
  return { content: [{ type: "text", text: JSON.stringify({ repo }, null, 2) }] };
12880
13014
  }
12881
13015
  },
12882
- // invite_agent mints a bearer credential, and agent mode reaches a headless
12883
- // `claude --print --dangerously-skip-permissions` loop where nothing
12884
- // confirms a call. Before this, no agent-mode tool took a repoId from the
12885
- // model at all, so the model's reach was one repo even when the credential
12886
- // reached every repo the owner owns.
13016
+ // Mints a bearer credential: owner mode and the god credential only.
12887
13017
  ...opts.credentialMinting ? [{
12888
13018
  name: "invite_agent",
12889
13019
  description: "Authorise a new agent to join one of your repos. This does NOT create the agent: it mints a single-use, expiring invite code and returns the exact command that redeems it. Two ways to finish. If you have a shell on the machine that agent will run on, create its working directory and run the command there. If you do not, give the user the command to paste. Either way the redeem is INTERACTIVE: `login` prompts for the agent name and specialization and does not yet read them from the invite, so expect to answer two questions and do not run it anywhere it cannot reach a terminal. The code is the only secret here and it is worthless once redeemed, which is why this is an invite rather than a token: a token would be long-lived and would sit in this conversation. Set workerType to the runtime that agent actually is, or it joins labelled 'human' and routing treats it wrongly.",
@@ -12907,8 +13037,8 @@ function buildProvisioningTools(client, opts = {}) {
12907
13037
  text: JSON.stringify({
12908
13038
  invite,
12909
13039
  code,
12910
- redeemCommand: `RELAI_CONFIG_DIR=~/.config/relai/${slot} pitboss login --api ${client.apiUrl} --invite ${code}${workerTypeFlag}`,
12911
- nextStep: "Run redeemCommand in the directory that agent will work from, or give it to the user to paste there. It will ask for an agent name and a specialization. The code is single-use and expires. One machine holds one identity per config dir, which is why the command sets RELAI_CONFIG_DIR \u2014 drop it and a machine that already has an agent will report success without redeeming anything."
13040
+ redeemCommand: `PITBOSS_CONFIG_DIR=~/.config/pitboss/${slot} RELAI_CONFIG_DIR=~/.config/pitboss/${slot} pitboss login --api ${client.apiUrl} --invite ${code}${workerTypeFlag}`,
13041
+ nextStep: "Run redeemCommand in the directory that agent will work from, or give it to the user to paste there. It will ask for an agent name and a specialization. The code is single-use and expires. One machine holds one identity per config dir, which is why the command sets the config dir \u2014 drop it and a machine that already has an agent will report success without redeeming anything."
12912
13042
  }, null, 2)
12913
13043
  }]
12914
13044
  };
@@ -13281,17 +13411,39 @@ function diffAttention(prev, tasks) {
13281
13411
  for (const s of next.values()) counts.set(s, (counts.get(s) ?? 0) + 1);
13282
13412
  const parts = [...counts.entries()].map(([s, n]) => `${n} ${s}`).sort();
13283
13413
  return {
13284
- notices: [`relai: ${next.size} item(s) need you (${parts.join(", ")}). Call list_attention.`],
13414
+ notices: [{ text: `relai: ${next.size} item(s) need you (${parts.join(", ")}). Call list_attention.`, ids: [...next.keys()] }],
13285
13415
  next
13286
13416
  };
13287
13417
  }
13288
13418
  const byId = new Map(tasks.map((t) => [t.id, t]));
13289
13419
  const notices = [];
13290
13420
  for (const [id, state] of next) {
13291
- if (prev.get(id) !== state) notices.push(describe(byId.get(id), state));
13421
+ if (prev.get(id) !== state) notices.push({ text: describe(byId.get(id), state), ids: [id] });
13292
13422
  }
13293
13423
  return { notices, next };
13294
13424
  }
13425
+ async function deliverAttention(prev, tasks, send) {
13426
+ const { notices, next } = diffAttention(prev, tasks);
13427
+ const undelivered = /* @__PURE__ */ new Set();
13428
+ for (const [i, n] of notices.entries()) {
13429
+ try {
13430
+ await send(n.text);
13431
+ } catch (err) {
13432
+ const held = notices.slice(i);
13433
+ console.error(`[relai-mcp] ${held.length} attention notice(s) held for the next poll:`, err instanceof Error ? err.message : err);
13434
+ held.forEach((h) => h.ids.forEach((id) => undelivered.add(id)));
13435
+ break;
13436
+ }
13437
+ }
13438
+ if (undelivered.size === 0) return next;
13439
+ if (prev === null) return null;
13440
+ for (const id of undelivered) {
13441
+ const before = prev.get(id);
13442
+ if (before) next.set(id, before);
13443
+ else next.delete(id);
13444
+ }
13445
+ return next;
13446
+ }
13295
13447
 
13296
13448
  // ../../shared/git/src/index.ts
13297
13449
  import { execFileSync } from "node:child_process";
@@ -13439,7 +13591,7 @@ var {
13439
13591
  OWNER_ID,
13440
13592
  TRANSPORT = "stdio"
13441
13593
  } = process.env;
13442
- var OWNER_MODE = Boolean(API_OWNER_TOKEN);
13594
+ var OWNER_MODE = Boolean(API_OWNER_TOKEN?.trim());
13443
13595
  if (OWNER_MODE) {
13444
13596
  if (!OWNER_ID || !OWNER_ID.startsWith("usr_")) {
13445
13597
  console.error("[relai-mcp] owner mode requires OWNER_ID (a 'usr_\u2026' id) alongside API_OWNER_TOKEN");
@@ -13449,7 +13601,7 @@ if (OWNER_MODE) {
13449
13601
  "[relai-mcp] owner mode: API_OWNER_TOKEN is a god-key credential \u2014 keep this server off the open internet (localhost bind + authenticating reverse proxy only)."
13450
13602
  );
13451
13603
  } else {
13452
- if (!API_SECRET) {
13604
+ if (!API_SECRET?.trim()) {
13453
13605
  console.error("[relai-mcp] API_SECRET is required");
13454
13606
  process.exit(1);
13455
13607
  }
@@ -13472,19 +13624,24 @@ var toolConfig = OWNER_MODE ? { ownerMode: true, ownerId: OWNER_ID } : { ownerMo
13472
13624
  if (OWNER_MODE) {
13473
13625
  const OWNER_POLL_INTERVAL_MS = Number(process.env.OWNER_POLL_INTERVAL_MS ?? 6e4);
13474
13626
  let seen = null;
13627
+ let polling = false;
13475
13628
  async function pollAttention() {
13629
+ if (polling) return;
13630
+ polling = true;
13476
13631
  try {
13477
13632
  const [attention, active] = await Promise.all([
13478
13633
  apiClient.getTasks({ status: "blocked,pending_verification,proposed" }),
13479
13634
  apiClient.getTasks({ status: "in_progress" })
13480
13635
  ]);
13481
- const { notices, next } = diffAttention(seen, [...attention, ...active]);
13482
- seen = next;
13483
- for (const data of notices) {
13484
- await server.server.sendLoggingMessage({ level: "warning", data });
13485
- }
13636
+ seen = await deliverAttention(
13637
+ seen,
13638
+ [...attention, ...active],
13639
+ (data) => server.server.sendLoggingMessage({ level: "warning", data })
13640
+ );
13486
13641
  } catch (err) {
13487
13642
  console.error("[relai-mcp] attention poll failed:", err instanceof Error ? err.message : err);
13643
+ } finally {
13644
+ polling = false;
13488
13645
  }
13489
13646
  }
13490
13647
  void pollAttention();
@@ -13559,20 +13716,20 @@ async function main() {
13559
13716
  const transport = new StdioServerTransport();
13560
13717
  await server.connect(transport);
13561
13718
  } else if (TRANSPORT === "http") {
13562
- const { SSEServerTransport: SSEServerTransport2 } = await Promise.resolve().then(() => (init_sse(), sse_exports));
13563
13719
  const http = await import("node:http");
13720
+ const { createHttpRequestListener: createHttpRequestListener2 } = await Promise.resolve().then(() => (init_http_transport(), http_transport_exports));
13721
+ const { resolveHttpCredential: resolveHttpCredential2, isBlankCredential: isBlankCredential2 } = await Promise.resolve().then(() => (init_http_auth(), http_auth_exports));
13564
13722
  const port = Number(process.env.MCP_PORT ?? 3001);
13565
13723
  const host = process.env.MCP_HOST ?? "127.0.0.1";
13566
- const httpServer = http.createServer(async (req, res) => {
13567
- if (req.method === "GET" && req.url === "/sse") {
13568
- const transport = new SSEServerTransport2("/messages", res);
13569
- await server.connect(transport);
13570
- } else if (req.method === "POST" && req.url === "/messages") {
13571
- res.writeHead(200).end();
13572
- } else {
13573
- res.writeHead(404).end();
13574
- }
13575
- });
13724
+ const credential = resolveHttpCredential2(process.env.MCP_HTTP_TOKEN, OWNER_MODE, API_OWNER_TOKEN, API_SECRET);
13725
+ if (isBlankCredential2(credential)) {
13726
+ console.error(
13727
+ "[relai-mcp] the HTTP transport credential (MCP_HTTP_TOKEN, API_SECRET, or API_OWNER_TOKEN) is empty or whitespace \u2014 refusing to start with a credential that would lock out every client"
13728
+ );
13729
+ process.exit(1);
13730
+ }
13731
+ const listener = createHttpRequestListener2(server, credential);
13732
+ const httpServer = http.createServer(listener);
13576
13733
  httpServer.listen(port, host, () => {
13577
13734
  console.error(`[relai-mcp] HTTP/SSE transport listening on ${host}:${port}`);
13578
13735
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitboss/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "Connect your coding agents to a pitboss instance",
6
6
  "keywords": [