@agilemonster/mcp-server 1.0.25 → 1.0.26

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
@@ -14,7 +14,9 @@ Seamlessly connects your Agile Monster boards with your favorite developer AI ag
14
14
 
15
15
  ## ⚡ Quick Start: Adding to your AI Agent
16
16
 
17
- Generate a **Personal API Key** in Agile Monster ([https://app.agilemonster.com](https://app.agilemonster.com) -> Profile -> API Keys). Your key will look like `am_live_...`.
17
+ The quickest way is the one command in [Connect a project folder](#-connect-a-project-folder-bootstrap) below: it does everything in this section for you.
18
+
19
+ To set a tool up by hand, generate a **Personal API Key** in Agile Monster ([https://app.agilemonster.com](https://app.agilemonster.com) -> Profile -> AI Agents & API Keys -> Generate API key). Your key will look like `am_live_...`.
18
20
 
19
21
  ### 1. Cursor (`.cursor/mcp.json` or Global Settings)
20
22
  ```json
@@ -58,14 +60,19 @@ Add to your Antigravity MCP directory (`~/.gemini/antigravity/mcp/agilemonster`
58
60
 
59
61
  ---
60
62
 
61
- ## 📁 1-Click Project Initialization (`init`)
63
+ ## 📁 Connect a project folder (`bootstrap`)
62
64
 
63
- To automatically bind your repository to an Agile Monster board and generate operational instructions for your AI agents in one command, run:
65
+ Open a terminal in your project folder and run this once (it needs [Node.js](https://nodejs.org) 18.14 or newer):
64
66
 
65
67
  ```bash
66
- npx @agilemonster/mcp-server init
68
+ npx -y @agilemonster/mcp-server bootstrap
67
69
  ```
68
70
 
71
+ It asks for your API key the first time, lets you pick the board, writes the files below, **installs the
72
+ background runner** (see the next section) and ends by telling you which coding agent on this computer is
73
+ ready. `init` and `setup` are the same command. Add `--board="Board name"` to skip the question, or
74
+ `--no-service` to leave the background runner out.
75
+
69
76
  This generates:
70
77
  1. `.agilemonster.json` — Binds the local repository to the board UUID, list columns, and default agent provider.
71
78
  2. `AGENTS.md` — Codified operational rules for autonomous agents (Cursor, Claude Code, Antigravity, etc.) so they know when to claim tasks, ask clarifying questions, log progress, and submit work for human testing.
@@ -93,14 +100,37 @@ Agents have access to these purpose-built lifecycle tools:
93
100
 
94
101
  ## 🧠 Who works a card that is queued from the board
95
102
 
96
- A card you assign to an agent in the web app is picked up by the background runner on your machine. The
97
- runner hands it to a **real coding agent running headless in the project folder** — the same agent you use
103
+ A card you assign to an agent in the web app is picked up by the background runner on your machine.
104
+
105
+ ### The background runner
106
+
107
+ `bootstrap` installs the runner as a service that starts with the computer, so cards are worked whether or
108
+ not a coding tool is open. Without it, cards are only picked up while an AI coding tool with this MCP server
109
+ is open in the project folder.
110
+
111
+ ```bash
112
+ npx -y @agilemonster/mcp-server service status # is it installed and running?
113
+ npx -y @agilemonster/mcp-server service install # install or repair it
114
+ npx -y @agilemonster/mcp-server service uninstall # stop and remove it
115
+ ```
116
+
117
+ - **macOS**: a launchd agent (`~/Library/LaunchAgents/com.agilemonster.pulse.plist`). **Linux**: a systemd
118
+ user unit (`agilemonster-runner.service`). **Windows**: not available as a service yet; keep your coding
119
+ tool open, or run `npx -y @agilemonster/mcp-server pulse-daemon` in a terminal.
120
+ - The service runs its own copy of this package from `~/.agilemonster/runner` and keeps it up to date.
121
+ - A runner that was set up by hand is left alone unless you pass `--force`.
122
+ - The board shows the result: next to a waiting card it says whether a computer is connected, whether it is
123
+ there right now, and which agent will do the work.
124
+
125
+ ### The agent that does the work
126
+
127
+ The runner hands a card to a **real coding agent running headless in the project folder** — the same agent you use
98
128
  in the IDE, with the same ability to search the code, open what it finds, run the build and fix what it broke:
99
129
 
100
130
  | Card's agent | Command line agent used | One-time setup |
101
131
  | :--- | :--- | :--- |
102
132
  | Cursor | `cursor-agent -p --force` | `curl https://cursor.com/install -fsS \| bash`, then `cursor-agent login` |
103
- | Claude Code | `claude -p --permission-mode acceptEdits` | `npm install -g @anthropic-ai/claude-code`, then `claude auth login` |
133
+ | Claude Code | `claude -p --permission-mode acceptEdits --allowedTools Bash` | `npm install -g @anthropic-ai/claude-code`, then `claude auth login` |
104
134
  | Codex | `codex exec --sandbox workspace-write` | `npm install -g @openai/codex`, then `codex login` |
105
135
 
106
136
  The runner also finds an agent that is not on your `PATH` — for example the Codex CLI that the Codex app and
@@ -153,7 +183,8 @@ What the runner guarantees:
153
183
  State lives outside your repositories: `~/.agilemonster/projects/` (per-project deployment history) and
154
184
  `~/.agilemonster/logs/daemon.jsonl` (structured daemon log).
155
185
 
156
- Card reply syntax: `retry` · `deploy: github_actions` · `deploy: docker_compose` · `deploy: manual` · `deploy: ./deploy/deploy.sh` · `live url: https://example.com`
186
+ When the runner asks a question, the card shows the answers it accepts as buttons, so nobody has to type
187
+ these. They still work as typed replies: `retry` · `deploy: github_actions` · `deploy: docker_compose` · `deploy: manual` · `deploy: ./deploy/deploy.sh` · `live url: https://example.com`
157
188
 
158
189
  ---
159
190
 
package/bin/mcp.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { runStdioServer, runBackgroundPulseDaemon } from '../dist/server.js';
3
- import { MCP_SERVER_VERSION } from '../dist/client.js';
3
+ import { AgileMonsterClient, MCP_SERVER_VERSION } from '../dist/client.js';
4
4
 
5
5
  const cliCommands = new Set([
6
6
  'bootstrap',
@@ -30,7 +30,31 @@ if (firstArg === '--version' || firstArg === '-v' || firstArg === 'version') {
30
30
  await ensureUserPath();
31
31
  forgetEngineReadiness();
32
32
  console.log(await describeAgents());
33
+ const { describeService } = await import('../dist/service.js');
34
+ console.log(`\n${await describeService()}`);
33
35
  process.exit(0);
36
+ } else if (firstArg === 'service') {
37
+ // The background runner that works cards with no coding tool open: install | status | uninstall
38
+ const { installService, uninstallService, describeService } = await import('../dist/service.js');
39
+ const { ensureUserPath } = await import('../dist/runtime.js');
40
+ await ensureUserPath();
41
+ const sub = process.argv[3] || 'status';
42
+ const force = process.argv.includes('--force');
43
+ if (sub === 'install') {
44
+ const key = new AgileMonsterClient().getApiKey();
45
+ // The service is not started inside a project folder, so it reads the key from the home config
46
+ if (key && !AgileMonsterClient.hasGlobalApiKey()) AgileMonsterClient.saveGlobalConfig({ api_key: key });
47
+ const res = await installService({ force, hasApiKey: Boolean(key) });
48
+ console.log(res.message);
49
+ process.exit(res.ok ? 0 : 1);
50
+ } else if (sub === 'uninstall' || sub === 'remove') {
51
+ const res = await uninstallService({ force });
52
+ console.log(res.message);
53
+ process.exit(res.ok ? 0 : 1);
54
+ } else {
55
+ console.log(await describeService());
56
+ process.exit(0);
57
+ }
34
58
  } else if (firstArg === 'pulse-daemon' || firstArg === '--pulse-daemon') {
35
59
  runBackgroundPulseDaemon(20000).catch((err) => {
36
60
  console.error('Fatal Pulse Daemon Error:', err);
@@ -38,6 +38,7 @@ async function main() {
38
38
  ide: ideArg,
39
39
  board: boardArg,
40
40
  provider: providerArg,
41
+ service: !args.includes('--no-service'),
41
42
  });
42
43
  break;
43
44
  }
@@ -48,6 +48,8 @@ export interface EngineStatus {
48
48
  export declare function engineStatuses(): Promise<EngineStatus[]>;
49
49
  /** What `agilemonster agents` prints: which agent works a queued card here, and the one step that would fix the others. */
50
50
  export declare function describeAgents(): Promise<string>;
51
+ /** How this machine works cards: with real agents, with the developer's own command, or with agents switched off. */
52
+ export declare function agentMode(): 'agents' | 'builtin' | 'custom';
51
53
  export declare function engineLabelFor(provider: string): string | null;
52
54
  /**
53
55
  * The agent CLIs this machine can run for a card, best first: the card's own agent, then any other one that
@@ -90,6 +92,8 @@ export interface AgentReport {
90
92
  summary: string;
91
93
  verificationSteps: string[];
92
94
  question: string;
95
+ /** Short answers the requester can pick with one tap, when the question has a few clear ones. */
96
+ options: string[];
93
97
  }
94
98
  /** Read the closing JSON object out of the agent's final message. Tolerates code fences and prose around it. */
95
99
  export declare function parseAgentReport(text: string): AgentReport;
@@ -243,6 +243,15 @@ async function readiness(spec, bin) {
243
243
  readyCache.set(bin, { at: Date.now(), reason });
244
244
  return reason;
245
245
  }
246
+ /** How this machine works cards: with real agents, with the developer's own command, or with agents switched off. */
247
+ export function agentMode() {
248
+ const pinned = (process.env.AGILEMONSTER_AGENT_ENGINE || '').trim().toLowerCase();
249
+ if (['builtin', 'built-in', 'off', 'none', '0', 'false'].includes(pinned))
250
+ return 'builtin';
251
+ if (customCommand() && (!pinned || pinned === 'auto' || pinned === 'custom'))
252
+ return 'custom';
253
+ return 'agents';
254
+ }
246
255
  export function engineLabelFor(provider) {
247
256
  const spec = ENGINES.find((e) => e.providers.includes((provider || '').toLowerCase()));
248
257
  return spec ? spec.label : null;
@@ -368,7 +377,7 @@ export function buildAgentPrompt(brief) {
368
377
  lines.push(`- An earlier attempt at this card left uncommitted changes in ${brief.earlierFiles.slice(0, 40).map((f) => `\`${f}\``).join(', ')}. They are yours to keep, fix or replace.` +
369
378
  (brief.earlierSummary ? ` It reported: ${brief.earlierSummary.trim().slice(0, 600)}` : ''));
370
379
  }
371
- lines.push('', '## When you are done', 'End your reply with one JSON object on its own, and nothing after it:', '{"status":"done","summary":"2-4 plain sentences for the person reviewing the card: what changed and where they will see it","verification_steps":["a step they can do in the running app","..."]}', 'Only if the work is impossible or unsafe without something that is not in the repository:', '{"status":"blocked","question":"the one specific thing you need, and what you already tried"}');
380
+ lines.push('', '## When you are done', 'End your reply with one JSON object on its own, and nothing after it:', '{"status":"done","summary":"2-4 plain sentences for the person reviewing the card: what changed and where they will see it","verification_steps":["a step they can do in the running app","..."]}', 'Only if the work is impossible or unsafe without something that is not in the repository:', '{"status":"blocked","question":"the one specific thing you need, and what you already tried","options":["up to four short answers the requester can pick with one tap, if the question has clear ones"]}');
372
381
  return lines.join('\n');
373
382
  }
374
383
  /** Read the closing JSON object out of the agent's final message. Tolerates code fences and prose around it. */
@@ -395,13 +404,16 @@ export function parseAgentReport(text) {
395
404
  summary: String(obj.summary || '').trim(),
396
405
  verificationSteps: steps,
397
406
  question: String(obj.question || '').trim(),
407
+ options: Array.isArray(obj.options)
408
+ ? obj.options.map((o) => String(o).trim()).filter((o) => o.length > 0 && o.length <= 80).slice(0, 4)
409
+ : [],
398
410
  };
399
411
  }
400
412
  catch { }
401
413
  end = clean.lastIndexOf('}', end - 1);
402
414
  }
403
415
  }
404
- return { status: 'unknown', summary: '', verificationSteps: [], question: '' };
416
+ return { status: 'unknown', summary: '', verificationSteps: [], question: '', options: [] };
405
417
  }
406
418
  /** The agent's prose without the closing JSON object: what to show a human when the report is missing a field. */
407
419
  export function proseOf(text, maxChars = 900) {
package/dist/client.d.ts CHANGED
@@ -1,5 +1,10 @@
1
+ import type { MachineReport } from './machine.js';
1
2
  import { ActiveClaim, AgileMonsterProjectConfig, AutonomousStepResponse, CardComment, DeployReport, Board, BoardAgentConfig, Card, ContextResponse, DiscoveredProject, PulseResponse } from './types.js';
2
- export declare const MCP_SERVER_VERSION = "1.0.25";
3
+ export declare const MCP_SERVER_VERSION = "1.0.26";
4
+ export type QuestionOption = string | {
5
+ label: string;
6
+ reply: string;
7
+ };
3
8
  /** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
4
9
  export declare class ApiError extends Error {
5
10
  readonly status: number;
@@ -22,6 +27,8 @@ export declare class AgileMonsterClient {
22
27
  setApiKey(key: string): void;
23
28
  loadProjectConfig(): AgileMonsterProjectConfig | null;
24
29
  saveProjectConfig(config: AgileMonsterProjectConfig, dir?: string): string;
30
+ /** True when ~/.agilemonster/config.json already holds a key (what a background service reads). */
31
+ static hasGlobalApiKey(): boolean;
25
32
  static saveGlobalConfig(data: {
26
33
  api_key?: string;
27
34
  }): string;
@@ -38,7 +45,8 @@ export declare class AgileMonsterClient {
38
45
  agents: BoardAgentConfig[];
39
46
  }>;
40
47
  pulse(options: {
41
- boardId: string;
48
+ /** Omitted by a background service that was not started inside a project folder. */
49
+ boardId?: string;
42
50
  provider?: string;
43
51
  projectName?: string;
44
52
  projectPath?: string;
@@ -50,6 +58,8 @@ export declare class AgileMonsterClient {
50
58
  installedAgents?: string[];
51
59
  /** Cards this process holds: the server extends their claim (heartbeat). */
52
60
  activeClaims?: ActiveClaim[];
61
+ /** This computer and the agents on it that can take a card (shown on the board next to a waiting card). */
62
+ machine?: MachineReport;
53
63
  }): Promise<PulseResponse>;
54
64
  /** Extend the claims this process holds. Used while a long job keeps the pulse loop busy. */
55
65
  heartbeat(activeClaims: ActiveClaim[]): Promise<{
@@ -93,7 +103,11 @@ export declare class AgileMonsterClient {
93
103
  cards: Card[];
94
104
  }>;
95
105
  claimTask(cardId: string, provider: string, runRef?: string, expectedBoardId?: string): Promise<Card>;
96
- askQuestion(cardId: string, provider: string, question: string): Promise<Card>;
106
+ /**
107
+ * `options` become reply buttons on the card: a plain string is both the button and the reply, a
108
+ * {label, reply} pair shows one thing and sends another ("Yes, deploy this way" -> "deploy: ./deploy.sh").
109
+ */
110
+ askQuestion(cardId: string, provider: string, question: string, options?: QuestionOption[]): Promise<Card>;
97
111
  addProgress(cardId: string, provider: string, update: string, tokensUsed?: number, costUsd?: number): Promise<Card>;
98
112
  recordSpend(cardId: string, provider: string, tokens: number, costUsd: number, note?: string): Promise<Card>;
99
113
  setBudget(cardId: string, maxBudgetUsd?: number | null, maxTokens?: number | null): Promise<Card>;
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@ import { execSync } from 'node:child_process';
4
4
  import { resolveDeployProfile } from './deploy-profile.js';
5
5
  import { loadMemory } from './deploy-memory.js';
6
6
  import { stripRemoteCredentials } from './redact.js';
7
- export const MCP_SERVER_VERSION = '1.0.25';
7
+ export const MCP_SERVER_VERSION = '1.0.26';
8
8
  /** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
9
9
  export class ApiError extends Error {
10
10
  status;
@@ -111,6 +111,19 @@ export class AgileMonsterClient {
111
111
  this.cachedConfig = config;
112
112
  return configPath;
113
113
  }
114
+ /** True when ~/.agilemonster/config.json already holds a key (what a background service reads). */
115
+ static hasGlobalApiKey() {
116
+ try {
117
+ const homedir = process.env.HOME || process.env.USERPROFILE || '';
118
+ if (!homedir)
119
+ return false;
120
+ const parsed = JSON.parse(fs.readFileSync(path.join(homedir, '.agilemonster', 'config.json'), 'utf8'));
121
+ return typeof parsed?.api_key === 'string' && parsed.api_key.trim() !== '';
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
114
127
  static saveGlobalConfig(data) {
115
128
  const homedir = process.env.HOME || process.env.USERPROFILE || '';
116
129
  if (!homedir)
@@ -216,6 +229,7 @@ export class AgileMonsterClient {
216
229
  discovered_models: options.discoveredModels,
217
230
  installed_agents: installed,
218
231
  active_claims: options.activeClaims && options.activeClaims.length > 0 ? options.activeClaims : undefined,
232
+ machine: options.machine,
219
233
  }),
220
234
  });
221
235
  }
@@ -453,11 +467,12 @@ export class AgileMonsterClient {
453
467
  */
454
468
  discoverLocalProjects() {
455
469
  const roots = new Set();
470
+ const homedir = process.env.HOME || process.env.USERPROFILE || '';
456
471
  const parentDir = path.dirname(this.cwd);
457
- if (parentDir && parentDir !== '/' && parentDir !== this.cwd) {
472
+ // A background service runs from the home folder: its parent holds other people's homes, not projects
473
+ if (parentDir && parentDir !== '/' && parentDir !== this.cwd && path.resolve(this.cwd) !== path.resolve(homedir || '/')) {
458
474
  roots.add(parentDir);
459
475
  }
460
- const homedir = process.env.HOME || process.env.USERPROFILE || '';
461
476
  if (homedir) {
462
477
  for (const sub of ['dev', 'Projects', 'Code', 'repos', 'Developer']) {
463
478
  const candidate = path.join(homedir, sub);
@@ -555,10 +570,14 @@ export class AgileMonsterClient {
555
570
  body: JSON.stringify({ provider, run_ref: runRef, board_id: boardId }),
556
571
  });
557
572
  }
558
- async askQuestion(cardId, provider, question) {
573
+ /**
574
+ * `options` become reply buttons on the card: a plain string is both the button and the reply, a
575
+ * {label, reply} pair shows one thing and sends another ("Yes, deploy this way" -> "deploy: ./deploy.sh").
576
+ */
577
+ async askQuestion(cardId, provider, question, options) {
559
578
  return this.request(`/api/agent/cards/${cardId}/ask-question`, {
560
579
  method: 'POST',
561
- body: JSON.stringify({ provider, question }),
580
+ body: JSON.stringify({ provider, question, options: options && options.length > 0 ? options.slice(0, 4) : undefined }),
562
581
  });
563
582
  }
564
583
  async addProgress(cardId, provider, update, tokensUsed, costUsd) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What this computer tells the board with every pulse: which machine it is, whether the runner is the
3
+ * background service or a coding tool that happens to be open, and which agents on it can take a card.
4
+ * The board turns that into what a person reads next to a waiting card ("Codex on Studio Mac is ready",
5
+ * "Cursor is not signed in").
6
+ */
7
+ export interface MachineReport {
8
+ id: string;
9
+ name: string;
10
+ platform: string;
11
+ runner: 'service' | 'ide';
12
+ mode: 'agents' | 'builtin' | 'custom';
13
+ agents: Array<{
14
+ id: string;
15
+ label: string;
16
+ ready: boolean;
17
+ problem: string | null;
18
+ }>;
19
+ }
20
+ /** A stable id for this computer, made once and kept in ~/.agilemonster/machine.json. */
21
+ export declare function machineIdentity(): {
22
+ id: string;
23
+ name: string;
24
+ };
25
+ export declare function machineReport(role: 'daemon' | 'stdio'): Promise<MachineReport>;
@@ -0,0 +1,32 @@
1
+ import crypto from 'node:crypto';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { agentMode, engineStatuses } from './agent-engine.js';
5
+ import { agileMonsterHome, readJson, writeJsonAtomic } from './runtime.js';
6
+ /** A stable id for this computer, made once and kept in ~/.agilemonster/machine.json. */
7
+ export function machineIdentity() {
8
+ const file = path.join(agileMonsterHome(), 'machine.json');
9
+ const saved = readJson(file);
10
+ let id = typeof saved?.id === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(saved.id) ? saved.id : '';
11
+ if (!id) {
12
+ id = crypto.randomUUID();
13
+ try {
14
+ writeJsonAtomic(file, { id });
15
+ }
16
+ catch { }
17
+ }
18
+ const name = (os.hostname() || 'computer').replace(/\.(local|lan|home|localdomain)$/i, '').slice(0, 120);
19
+ return { id, name };
20
+ }
21
+ export async function machineReport(role) {
22
+ const { id, name } = machineIdentity();
23
+ const agents = await engineStatuses();
24
+ return {
25
+ id,
26
+ name,
27
+ platform: process.platform,
28
+ runner: role === 'daemon' ? 'service' : 'ide',
29
+ mode: agentMode(),
30
+ agents: agents.map((a) => ({ id: a.id, label: a.label, ready: !a.problem, problem: a.problem })),
31
+ };
32
+ }
@@ -21,6 +21,8 @@ export declare class AgileMonsterOrchestrator {
21
21
  provider?: string;
22
22
  ide?: string;
23
23
  board?: string;
24
+ /** False skips installing the background runner (`--no-service`). */
25
+ service?: boolean;
24
26
  }): Promise<void>;
25
27
  bootstrapInteractive(options?: {
26
28
  provider?: string;
@@ -6,6 +6,9 @@ import { AGENT_GUIDELINES_MARKDOWN } from './guidelines.js';
6
6
  import { configureIdeTargets } from './ide-config.js';
7
7
  import { ensureLocalGitExcludes } from './deploy-runner.js';
8
8
  import { stripRemoteCredentials } from './redact.js';
9
+ import { describeAgents } from './agent-engine.js';
10
+ import { ensureUserPath } from './runtime.js';
11
+ import { installService } from './service.js';
9
12
  export class AgileMonsterOrchestrator {
10
13
  client;
11
14
  constructor(client = new AgileMonsterClient()) {
@@ -313,15 +316,38 @@ export class AgileMonsterOrchestrator {
313
316
  // Configure IDEs (Cursor, Claude Code, Windsurf, Antigravity, VS Code)
314
317
  this.configureIdes(apiKey, options?.ide || options?.provider);
315
318
  this.ensureGitignore();
316
- console.log('\n🎉 Project successfully connected to Agile Monster!');
319
+ // The background runner: without it cards are only worked while a coding tool is open in this folder
320
+ let background = false;
321
+ if (options?.service !== false && process.env.AGILEMONSTER_NO_SERVICE !== '1') {
322
+ console.log('\n⚙️ Setting up the background runner, so cards are worked with no coding tool open...');
323
+ try {
324
+ await ensureUserPath();
325
+ // The service is not started inside a project folder, so it reads the key from the home config
326
+ if (!AgileMonsterClient.hasGlobalApiKey())
327
+ AgileMonsterClient.saveGlobalConfig({ api_key: apiKey });
328
+ const svc = await installService({ hasApiKey: true });
329
+ background = svc.outcome === 'installed' || svc.outcome === 'kept';
330
+ console.log(`${svc.ok ? '✅' : '⚠️ '} ${svc.message}`);
331
+ }
332
+ catch (err) {
333
+ console.log(`⚠️ The background runner could not be set up: ${err instanceof Error ? err.message : String(err)}`);
334
+ }
335
+ }
336
+ // Which coding agent will actually do the work, and the one step that fixes one that is not ready
337
+ try {
338
+ console.log(`\n${await describeAgents()}`);
339
+ }
340
+ catch { }
341
+ console.log('\n🎉 This folder is connected to Agile Monster.');
317
342
  console.log('\n======================================================');
318
- console.log(' 🚀 HOW TO USE IT NOW ');
343
+ console.log(' 🚀 WHAT HAPPENS NOW ');
319
344
  console.log('======================================================');
320
- console.log(`1. Open board "${targetBoard.name}" on https://app.agilemonster.com`);
321
- console.log(`2. Open any card and assign Worker to "${shortTag ? `Cursor-${shortTag}` : `Cursor: ${targetBoard.name}`}" (or Claude Code / Antigravity).`);
322
- console.log('3. In your AI IDE chat inside this project, simply say:');
323
- console.log(' "Check my Agile Monster tasks and start the next queued card."');
324
- console.log(' Or connect another project folder (e.g. Planogram.ai in Cursor) anytime by running:');
345
+ console.log(`1. Open the board "${targetBoard.name}" on https://app.agilemonster.com`);
346
+ console.log(`2. Add a card that says what you want, open it, and under "Assign to AI Agent or Team Member" choose "${shortTag ? `Cursor-${shortTag}` : `Cursor: ${targetBoard.name}`}" (or another agent).`);
347
+ console.log(background
348
+ ? '3. That is all. The card is picked up within about 20 seconds, worked in this folder, and comes back to you for review.'
349
+ : '3. Keep your AI coding tool open in this folder: the card is picked up within about 20 seconds while it is open, and comes back to you for review.');
350
+ console.log('\nTo connect another project folder, run the same command there:');
325
351
  console.log(' cd /path/to/other-project && npx -y @agilemonster/mcp-server bootstrap\n');
326
352
  }
327
353
  async bootstrapInteractive(options) {
package/dist/server.d.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- import { AgileMonsterClient } from './client.js';
2
+ import { AgileMonsterClient, QuestionOption } from './client.js';
3
3
  import { ActiveClaim, Card } from './types.js';
4
4
  import { BoundProfile } from './deploy-profile.js';
5
+ /**
6
+ * The answers a deploy question accepts, as reply buttons: nobody should have to type `deploy: ./deploy.sh`.
7
+ * A first-time route gets "yes, this way" and "I deploy it myself"; a failure gets "try again".
8
+ */
9
+ export declare function deployReplyOptions(res: {
10
+ errorKind?: string | null;
11
+ method?: string | null;
12
+ command?: string | null;
13
+ }): QuestionOption[];
5
14
  export declare function createServer(client?: AgileMonsterClient): Server<{
6
15
  method: string;
7
16
  params?: {
package/dist/server.js CHANGED
@@ -8,6 +8,8 @@ import { spawn, execSync, execFileSync } from 'node:child_process';
8
8
  import { isUtf8 } from 'node:buffer';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { AgileMonsterClient, MCP_SERVER_VERSION, isClaimConflict } from './client.js';
11
+ import { machineReport } from './machine.js';
12
+ import { managedRunnerDir, updateArgs } from './service.js';
11
13
  import { AGENT_GUIDELINES_MARKDOWN } from './guidelines.js';
12
14
  import { knownSecrets, redactResult } from './redact.js';
13
15
  import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, trustBoundRoute, } from './deploy-profile.js';
@@ -15,6 +17,21 @@ import { addPendingVerification, getCardState, loadMemory, recordAttempt, remove
15
17
  import { checkCiOnce, describeFailureForHuman, ensureLocalGitExcludes, parseDeployDirective, runDeployment, } from './deploy-runner.js';
16
18
  import { baselineFor, buildAgentPrompt, changesSince, clearBaseline, engineLabelFor, engineStatuses, markNewFilesForDiff, parseAgentReport, proseOf, resolveEngines, restoreWorkingTree, runEngine, } from './agent-engine.js';
17
19
  import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath, errorMessage, exec, log, readJson, which, writeJsonAtomic, } from './runtime.js';
20
+ /**
21
+ * The answers a deploy question accepts, as reply buttons: nobody should have to type `deploy: ./deploy.sh`.
22
+ * A first-time route gets "yes, this way" and "I deploy it myself"; a failure gets "try again".
23
+ */
24
+ export function deployReplyOptions(res) {
25
+ if (res.errorKind === 'needs_confirmation') {
26
+ const route = (res.command || res.method || '').trim();
27
+ const options = [];
28
+ if (route)
29
+ options.push({ label: 'Yes, deploy this way', reply: `deploy: ${route}` });
30
+ options.push({ label: 'I deploy it myself', reply: 'deploy: manual' });
31
+ return options;
32
+ }
33
+ return [{ label: 'Try again', reply: 'retry' }];
34
+ }
18
35
  export function createServer(client = new AgileMonsterClient()) {
19
36
  const server = new Server({
20
37
  name: 'agilemonster-mcp-server',
@@ -263,6 +280,11 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
263
280
  type: 'string',
264
281
  description: 'The question for the user.',
265
282
  },
283
+ options: {
284
+ type: 'array',
285
+ items: { type: 'string' },
286
+ description: 'Optional: up to four short answers the user can pick with one tap. The card shows them as buttons, and the user can still type a different answer.',
287
+ },
266
288
  },
267
289
  required: ['card_id', 'provider', 'question'],
268
290
  },
@@ -640,7 +662,10 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
640
662
  const cardId = args.card_id;
641
663
  const provider = args.provider || projectConfig?.provider || 'antigravity';
642
664
  const question = args.question;
643
- const card = await client.askQuestion(cardId, provider, question);
665
+ const answers = Array.isArray(args.options)
666
+ ? args.options.map((o) => String(o).trim()).filter((o) => o.length > 0 && o.length <= 80).slice(0, 4)
667
+ : [];
668
+ const card = await client.askQuestion(cardId, provider, question, answers);
644
669
  releaseClaim(cardId);
645
670
  return {
646
671
  content: [
@@ -1015,11 +1040,16 @@ function triggerBackgroundSelfUpdate(targetVersion) {
1015
1040
  catch { }
1016
1041
  }
1017
1042
  }
1018
- // 2. Install @agilemonster/mcp-server@latest into global/npx cache, then exit once idle so launchd respawns the daemon
1019
- const child = spawn('sh', ['-c', 'npm install -g @agilemonster/mcp-server@latest >/dev/null 2>&1 || npx -y @agilemonster/mcp-server@latest --version >/dev/null 2>&1'], {
1020
- stdio: 'ignore',
1021
- detached: true,
1022
- });
1043
+ // 2. Fetch the new version, then exit once idle so launchd/systemd starts it. The background service
1044
+ // updates its own copy in place; a runner started any other way refreshes the global install / npx cache.
1045
+ const ownDir = managedRunnerDir(fileURLToPath(import.meta.url));
1046
+ const npm = which('npm');
1047
+ const child = ownDir && npm
1048
+ ? spawn(npm, updateArgs(ownDir), { stdio: 'ignore', detached: true, cwd: ownDir })
1049
+ : spawn('sh', ['-c', 'npm install -g @agilemonster/mcp-server@latest >/dev/null 2>&1 || npx -y @agilemonster/mcp-server@latest --version >/dev/null 2>&1'], {
1050
+ stdio: 'ignore',
1051
+ detached: true,
1052
+ });
1023
1053
  child.on('exit', () => {
1024
1054
  appendIdeActivityLog(cwd, {
1025
1055
  cardId: 'system-update',
@@ -1579,7 +1609,7 @@ async function runRedeployOnlyCard(client, card, projectPath, provider, workerLa
1579
1609
  pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
1580
1610
  }));
1581
1611
  removeTaskLock(lockFile);
1582
- await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' ? describeFailureForHuman(res) : `The redeploy did not go through.\n\n${describeFailureForHuman(res)}`);
1612
+ await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' ? describeFailureForHuman(res) : `The redeploy did not go through.\n\n${describeFailureForHuman(res)}`, deployReplyOptions(res));
1583
1613
  return false;
1584
1614
  }
1585
1615
  if (res.pending && res.pendingVerification)
@@ -1683,7 +1713,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1683
1713
  // Nothing is ever confirmed on the human's behalf.
1684
1714
  removeTaskLock(lockFile);
1685
1715
  setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
1686
- await client.askQuestion(card.id, provider, `I still need an answer before I deploy this project for the first time. Reply \`deploy: ${pendingRoute.command || pendingRoute.method}\` to confirm, \`deploy: manual\` if you deploy it yourself, or tell me the right route.`);
1716
+ await client.askQuestion(card.id, provider, `I still need an answer before I deploy this project for the first time. Reply \`deploy: ${pendingRoute.command || pendingRoute.method}\` to confirm, \`deploy: manual\` if you deploy it yourself, or tell me the right route.`, deployReplyOptions({ errorKind: 'needs_confirmation', method: pendingRoute.method, command: pendingRoute.command }));
1687
1717
  return 'failed';
1688
1718
  }
1689
1719
  // No fresh reply at all: a re-queue of a deploy-failed card means "try the same deployment again".
@@ -1736,7 +1766,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1736
1766
  });
1737
1767
  await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation'
1738
1768
  ? describeFailureForHuman(res)
1739
- : `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}`);
1769
+ : `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}`, deployReplyOptions(res));
1740
1770
  return 'failed';
1741
1771
  }
1742
1772
  if (res.pending && res.pendingVerification) {
@@ -1904,7 +1934,7 @@ async function produceChangeWithAgent(run) {
1904
1934
  return files.length > 0;
1905
1935
  };
1906
1936
  run.rollback.fn = async () => revert((await changesSince(projectPath, baseline)).changed);
1907
- const backToHuman = async (question, summary, files) => {
1937
+ const backToHuman = async (question, summary, files, options = []) => {
1908
1938
  run.rollback.fn = null;
1909
1939
  removeTaskLock(lockFile);
1910
1940
  appendIdeActivityLog(projectPath, {
@@ -1916,9 +1946,10 @@ async function produceChangeWithAgent(run) {
1916
1946
  summary,
1917
1947
  files,
1918
1948
  });
1919
- await client.askQuestion(card.id, provider, question);
1949
+ await client.askQuestion(card.id, provider, question, options);
1920
1950
  return 'handled';
1921
1951
  };
1952
+ const TRY_AGAIN = [{ label: 'Try again', reply: 'Please try again.' }];
1922
1953
  const prompt = buildAgentPrompt({
1923
1954
  title: card.title || '',
1924
1955
  description: card.description || '',
@@ -1974,11 +2005,11 @@ async function produceChangeWithAgent(run) {
1974
2005
  await revert(change.changed);
1975
2006
  return backToHuman(`I need to change ${foreign.map((f) => `\`${f}\``).join(', ')}, but ${foreign.length === 1 ? 'that file has' : 'those files have'} uncommitted changes in \`${projectPath}\` that are not mine. Committing my edit would also commit that unfinished work, so I stopped and put everything back.\n\n${started.interrupted
1976
2007
  ? 'An earlier run of this card was interrupted before it finished, so these may be its leftovers rather than your work. If you did not make them, discard them (`git checkout -- <file>`); otherwise commit or stash them. Then reply here and I will continue.'
1977
- : 'Commit or stash those changes, then reply here and I will continue.'}`, `Stopped: ${foreign.join(', ')} had uncommitted changes`, foreign);
2008
+ : 'Commit or stash those changes, then reply here and I will continue.'}`, `Stopped: ${foreign.join(', ')} had uncommitted changes`, foreign, [{ label: 'Done, continue', reply: 'I committed or stashed those changes. Please continue.' }]);
1978
2009
  }
1979
2010
  if (!result.ok) {
1980
2011
  await revert(change.changed);
1981
- return backToHuman(`${engine.label} worked on this for ${Math.max(1, Math.round(result.durationMs / 60000))} minute(s) but did not finish: ${result.failure || 'it stopped without a result'}.\n\nI put the files back the way they were. Reply here to have it try again — if the card is large, splitting it into smaller cards helps.`, `${engine.label} did not finish: ${result.failure || 'no result'}`, []);
2012
+ return backToHuman(`${engine.label} worked on this for ${Math.max(1, Math.round(result.durationMs / 60000))} minute(s) but did not finish: ${result.failure || 'it stopped without a result'}.\n\nI put the files back the way they were. Reply here to have it try again — if the card is large, splitting it into smaller cards helps.`, `${engine.label} did not finish: ${result.failure || 'no result'}`, [], TRY_AGAIN);
1982
2013
  }
1983
2014
  let report = parseAgentReport(result.text);
1984
2015
  if (report.status === 'blocked' || change.changed.length === 0) {
@@ -1986,7 +2017,7 @@ async function produceChangeWithAgent(run) {
1986
2017
  const said = report.question || report.summary || proseOf(result.text);
1987
2018
  return backToHuman(report.status === 'blocked' && report.question
1988
2019
  ? report.question
1989
- : `${engine.label} finished without changing any files.${said ? `\n\n${said}` : ''}\n\nIf something is still missing, reply here with what you expected to change.`, `Needs input: ${said.slice(0, 400)}`, []);
2020
+ : `${engine.label} finished without changing any files.${said ? `\n\n${said}` : ''}\n\nIf something is still missing, reply here with what you expected to change.`, `Needs input: ${said.slice(0, 400)}`, [], report.status === 'blocked' && report.question ? report.options : []);
1990
2021
  }
1991
2022
  // 2. The runner's own build gate. The agent built it too, so a failure here is usually this machine's
1992
2023
  // environment — check that before sending the agent after an error that was never its own.
@@ -2032,7 +2063,7 @@ async function produceChangeWithAgent(run) {
2032
2063
  const stillForeign = change.overlapping.filter((f) => !carryOverFiles.includes(f));
2033
2064
  if (!gate.ok || change.changed.length === 0 || stillForeign.length > 0) {
2034
2065
  await revert(change.changed);
2035
- return backToHuman(`${engine.label} made the change, but the project did not build with it, and its attempt to fix that did not get the build green. I reverted everything rather than ship a broken build.\n\n\`\`\`\n${buildErrorExcerpt(gate.errorOutput || '')}\n\`\`\`\n\nYour description was clear — nothing more is needed from you on that. Reply here to have it try again.`, `Reverted: the build failed after ${engine.label}'s change`, []);
2066
+ return backToHuman(`${engine.label} made the change, but the project did not build with it, and its attempt to fix that did not get the build green. I reverted everything rather than ship a broken build.\n\n\`\`\`\n${buildErrorExcerpt(gate.errorOutput || '')}\n\`\`\`\n\nYour description was clear — nothing more is needed from you on that. Reply here to have it try again.`, `Reverted: the build failed after ${engine.label}'s change`, [], TRY_AGAIN);
2036
2067
  }
2037
2068
  }
2038
2069
  }
@@ -2505,7 +2536,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2505
2536
  const changed = modifiedFiles.map((f) => `\`${f}\``).join(', ');
2506
2537
  await client.askQuestion(card.id, provider, deployInfo.errorKind === 'needs_confirmation'
2507
2538
  ? `I made the code change (${changed}). One question before I deploy this project for the first time:\n\n${describeFailureForHuman(deployInfo)}`
2508
- : `I made the code change (${changed}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\n${describeFailureForHuman(deployInfo)}`);
2539
+ : `I made the code change (${changed}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\n${describeFailureForHuman(deployInfo)}`, deployReplyOptions(deployInfo));
2509
2540
  return false;
2510
2541
  }
2511
2542
  if (deployInfo.pending && deployInfo.pendingVerification) {
@@ -2820,7 +2851,7 @@ async function processPendingVerifications(client, projectPaths) {
2820
2851
  attempts: 1,
2821
2852
  last_error: detail,
2822
2853
  });
2823
- await client.askQuestion(p.card_id, p.provider, `The deployment I started for this card did not succeed, so the change is NOT live yet.\n\nWhat failed: ${detail}\n\nFix the failing step and reply **retry**, or tell me the right route with \`deploy: <method or command>\`.`);
2854
+ await client.askQuestion(p.card_id, p.provider, `The deployment I started for this card did not succeed, so the change is NOT live yet.\n\nWhat failed: ${detail}\n\nFix the failing step and reply **retry**, or tell me the right route with \`deploy: <method or command>\`.`, deployReplyOptions({ errorKind: 'ci_failed' }));
2824
2855
  await client.reportDeployment({ card_id: p.card_id, project_path: projectPath, method: 'github_actions', ok: false, error: detail.slice(0, 480), error_kind: 'ci_failed', commit: short });
2825
2856
  }
2826
2857
  else if (ageMs > 90 * 60 * 1000) {
@@ -2887,6 +2918,7 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2887
2918
  let holdsWorkerLock = false;
2888
2919
  let journalsRecovered = false;
2889
2920
  let consecutiveFailures = 0;
2921
+ let warnedNoKey = false;
2890
2922
  const autoProvisionSavedBindings = (apiKey, bindings) => {
2891
2923
  for (const b of bindings) {
2892
2924
  const projPath = (b.project_path || '').trim();
@@ -2965,19 +2997,28 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2965
2997
  try {
2966
2998
  const config = client.loadProjectConfig();
2967
2999
  const apiKey = client.getApiKey();
2968
- if (!config?.board_id || !apiKey) {
3000
+ // The background service is not started inside a project folder: it has no board of its own and
3001
+ // serves every folder linked on this machine. A coding tool's MCP server only pulses for its project.
3002
+ const homeBoardId = config?.board_id || '';
3003
+ if (!apiKey || (!homeBoardId && role !== 'daemon')) {
3004
+ if (role === 'daemon' && !apiKey && !warnedNoKey) {
3005
+ warnedNoKey = true;
3006
+ log.warn('daemon.no_api_key', { hint: 'run `npx -y @agilemonster/mcp-server bootstrap` in a project folder' });
3007
+ }
2969
3008
  return;
2970
3009
  }
3010
+ const homePath = homeBoardId ? config?.project_path || process.cwd() : '';
2971
3011
  const includeDiscovery = pulseCount === 0 || pulseCount % 3 === 0;
2972
3012
  pulseCount++;
2973
3013
  const res = await client.pulse({
2974
- boardId: config.board_id,
3014
+ boardId: homeBoardId || undefined,
2975
3015
  provider: 'any',
2976
- projectName: config.project_name || path.basename(process.cwd()),
2977
- projectPath: config.project_path || process.cwd(),
3016
+ projectName: homeBoardId ? config?.project_name || path.basename(process.cwd()) : undefined,
3017
+ projectPath: homePath || undefined,
2978
3018
  discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
2979
3019
  discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
2980
3020
  activeClaims: activeClaims(),
3021
+ machine: await machineReport(role).catch(() => undefined),
2981
3022
  });
2982
3023
  if (consecutiveFailures > 0) {
2983
3024
  log.info('pulse.recovered', { after_failures: consecutiveFailures });
@@ -2987,13 +3028,14 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2987
3028
  autoProvisionSavedBindings(apiKey, res.saved_bindings);
2988
3029
  }
2989
3030
  const queued = res.queued_cards || [];
2990
- syncIdeTaskRules(config.board_name || 'Board', queued);
3031
+ if (homeBoardId)
3032
+ syncIdeTaskRules(config?.board_name || 'Board', queued);
2991
3033
  for (const card of queued) {
2992
3034
  if (!notifiedCardIds.has(card.id)) {
2993
3035
  notifiedCardIds.add(card.id);
2994
3036
  const worker = card.agent_display_name || card.agent_provider || 'AI Agent';
2995
3037
  const modelTag = card.agent_model && card.agent_model !== 'auto' ? ` [${card.agent_model}]` : '';
2996
- sendDesktopNotification(`Agile Monster -> ${worker}${modelTag}`, `New task queued on ${config.board_name || 'Board'}: "${card.title}"`);
3038
+ sendDesktopNotification(`Agile Monster -> ${worker}${modelTag}`, `New task queued on ${config?.board_name || 'Board'}: "${card.title}"`);
2997
3039
  try {
2998
3040
  if (server) {
2999
3041
  await server.sendResourceUpdated({ uri: 'agilemonster://project/context' });
@@ -3013,7 +3055,7 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
3013
3055
  const boundQueued = res.all_bound_queued_cards || [];
3014
3056
  for (const qCard of boundQueued) {
3015
3057
  const targetPath = (qCard.bound_project_path || '').trim() ||
3016
- (qCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
3058
+ (homeBoardId && qCard.board_id === homeBoardId ? homePath : '');
3017
3059
  if (targetPath && fs.existsSync(targetPath)) {
3018
3060
  await executeQueuedCardAutonomously(client, qCard, targetPath);
3019
3061
  }
@@ -3024,12 +3066,12 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
3024
3066
  const boundDone = res.all_bound_done_cards || [];
3025
3067
  for (const dCard of boundDone) {
3026
3068
  const targetPath = (dCard.bound_project_path || '').trim() ||
3027
- (dCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
3069
+ (homeBoardId && dCard.board_id === homeBoardId ? homePath : '');
3028
3070
  if (targetPath && fs.existsSync(targetPath)) {
3029
3071
  await deployApprovedCardIfNeeded(client, dCard, targetPath);
3030
3072
  }
3031
3073
  }
3032
- const knownPaths = new Set([config.project_path || process.cwd()]);
3074
+ const knownPaths = new Set(homePath ? [homePath] : []);
3033
3075
  for (const b of res.saved_bindings || []) {
3034
3076
  if (b.project_path)
3035
3077
  knownPaths.add(b.project_path.trim());
@@ -0,0 +1,82 @@
1
+ import { ExecOptions, ExecResult } from './runtime.js';
2
+ /**
3
+ * Runs the card runner as a background service that starts with the computer.
4
+ *
5
+ * Without this, cards queued from the board are only worked while an AI coding tool happens to be open in
6
+ * the project folder (its MCP server pulses as a side effect). People do not know that, so their first card
7
+ * sits in "queued" for ever. `bootstrap` installs the service; `service install|status|uninstall` manage it.
8
+ *
9
+ * The service runs its own copy of the package from ~/.agilemonster/runner, so it does not depend on an npx
10
+ * cache that can be cleared or on a project folder that can move, and it updates that copy itself.
11
+ *
12
+ * macOS: a launchd agent. Linux: a systemd user unit. Windows: not yet.
13
+ */
14
+ export declare const SERVICE_LABEL = "com.agilemonster.pulse";
15
+ export declare const SYSTEMD_UNIT = "agilemonster-runner.service";
16
+ export type ServiceKind = 'launchd' | 'systemd';
17
+ export interface ServiceDeps {
18
+ platform: NodeJS.Platform;
19
+ home: string;
20
+ /** Machine-local Agile Monster folder (~/.agilemonster). */
21
+ amHome: string;
22
+ uid: number | null;
23
+ node: string;
24
+ npm: string | null;
25
+ pathEnv: string;
26
+ exec: (command: string, args?: string[], options?: ExecOptions) => Promise<ExecResult>;
27
+ }
28
+ /**
29
+ * The node binary the service should start. `process.execPath` is often a versioned folder (Homebrew's
30
+ * Cellar/node@24/24.21.0_1/…) that disappears with the next upgrade and would leave the service unable to
31
+ * start; the `node` on PATH is a stable link to the same binary, so prefer it when it is the same one.
32
+ */
33
+ export declare function stableNodePath(): string;
34
+ export declare function defaultServiceDeps(): ServiceDeps;
35
+ export declare function runnerDir(amHome: string): string;
36
+ /** The service's own copy of this package's entry point. */
37
+ export declare function runnerEntry(amHome: string): string;
38
+ export declare function serviceKind(platform: NodeJS.Platform): ServiceKind | null;
39
+ export declare function unitPath(kind: ServiceKind, home: string): string;
40
+ export interface UnitOptions {
41
+ node: string;
42
+ entry: string;
43
+ home: string;
44
+ logDir: string;
45
+ pathEnv: string;
46
+ }
47
+ export declare function launchdPlist(o: UnitOptions): string;
48
+ export declare function systemdUnit(o: UnitOptions): string;
49
+ /** The program an existing unit file starts (the script after the node binary), or null when unreadable. */
50
+ export declare function unitTarget(kind: ServiceKind, content: string): string | null;
51
+ export interface ServiceStatus {
52
+ supported: boolean;
53
+ kind: ServiceKind | null;
54
+ unitPath: string | null;
55
+ installed: boolean;
56
+ /** True when the unit starts the copy this module installs (not a hand-made or development setup). */
57
+ managed: boolean;
58
+ target: string | null;
59
+ running: boolean;
60
+ }
61
+ export declare function serviceStatus(deps?: ServiceDeps): Promise<ServiceStatus>;
62
+ export interface ServiceResult {
63
+ ok: boolean;
64
+ /** 'installed' | 'kept' (someone else's setup left alone) | 'unsupported' | 'failed' | 'removed' | 'absent' */
65
+ outcome: 'installed' | 'kept' | 'unsupported' | 'failed' | 'removed' | 'absent';
66
+ message: string;
67
+ }
68
+ export declare function installService(options: {
69
+ force?: boolean;
70
+ hasApiKey: boolean;
71
+ }, deps?: ServiceDeps): Promise<ServiceResult>;
72
+ export declare function uninstallService(options?: {
73
+ force?: boolean;
74
+ }, deps?: ServiceDeps): Promise<ServiceResult>;
75
+ /** npm arguments that install or update the service's own copy of the package. */
76
+ export declare function updateArgs(dir: string, version?: string): string[];
77
+ /**
78
+ * When this process is the service's own copy, the folder to update in place. Null for a copy started by
79
+ * npx, a global install or a source checkout: those update the way they were installed.
80
+ */
81
+ export declare function managedRunnerDir(runningFile: string, amHome?: string): string | null;
82
+ export declare function describeService(deps?: ServiceDeps): Promise<string>;
@@ -0,0 +1,327 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { agileMonsterHome, exec, which } from './runtime.js';
5
+ /**
6
+ * Runs the card runner as a background service that starts with the computer.
7
+ *
8
+ * Without this, cards queued from the board are only worked while an AI coding tool happens to be open in
9
+ * the project folder (its MCP server pulses as a side effect). People do not know that, so their first card
10
+ * sits in "queued" for ever. `bootstrap` installs the service; `service install|status|uninstall` manage it.
11
+ *
12
+ * The service runs its own copy of the package from ~/.agilemonster/runner, so it does not depend on an npx
13
+ * cache that can be cleared or on a project folder that can move, and it updates that copy itself.
14
+ *
15
+ * macOS: a launchd agent. Linux: a systemd user unit. Windows: not yet.
16
+ */
17
+ export const SERVICE_LABEL = 'com.agilemonster.pulse';
18
+ export const SYSTEMD_UNIT = 'agilemonster-runner.service';
19
+ const PACKAGE = '@agilemonster/mcp-server';
20
+ /**
21
+ * The node binary the service should start. `process.execPath` is often a versioned folder (Homebrew's
22
+ * Cellar/node@24/24.21.0_1/…) that disappears with the next upgrade and would leave the service unable to
23
+ * start; the `node` on PATH is a stable link to the same binary, so prefer it when it is the same one.
24
+ */
25
+ export function stableNodePath() {
26
+ const onPath = which('node');
27
+ if (onPath) {
28
+ try {
29
+ if (fs.realpathSync(onPath) === fs.realpathSync(process.execPath))
30
+ return onPath;
31
+ }
32
+ catch { }
33
+ }
34
+ return process.execPath;
35
+ }
36
+ export function defaultServiceDeps() {
37
+ return {
38
+ platform: process.platform,
39
+ home: os.homedir(),
40
+ amHome: agileMonsterHome(),
41
+ uid: typeof process.getuid === 'function' ? process.getuid() : null,
42
+ node: stableNodePath(),
43
+ npm: which('npm'),
44
+ pathEnv: process.env.PATH || '',
45
+ exec,
46
+ };
47
+ }
48
+ export function runnerDir(amHome) {
49
+ return path.join(amHome, 'runner');
50
+ }
51
+ /** The service's own copy of this package's entry point. */
52
+ export function runnerEntry(amHome) {
53
+ return path.join(runnerDir(amHome), 'node_modules', '@agilemonster', 'mcp-server', 'bin', 'mcp.js');
54
+ }
55
+ export function serviceKind(platform) {
56
+ if (platform === 'darwin')
57
+ return 'launchd';
58
+ if (platform === 'linux')
59
+ return 'systemd';
60
+ return null;
61
+ }
62
+ export function unitPath(kind, home) {
63
+ return kind === 'launchd'
64
+ ? path.join(home, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`)
65
+ : path.join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT);
66
+ }
67
+ function xml(value) {
68
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
69
+ }
70
+ export function launchdPlist(o) {
71
+ return `<?xml version="1.0" encoding="UTF-8"?>
72
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
73
+ <plist version="1.0">
74
+ <dict>
75
+ <key>Label</key>
76
+ <string>${SERVICE_LABEL}</string>
77
+ <key>ProgramArguments</key>
78
+ <array>
79
+ <string>${xml(o.node)}</string>
80
+ <string>${xml(o.entry)}</string>
81
+ <string>pulse-daemon</string>
82
+ </array>
83
+ <key>WorkingDirectory</key>
84
+ <string>${xml(o.home)}</string>
85
+ <key>EnvironmentVariables</key>
86
+ <dict>
87
+ <key>HOME</key>
88
+ <string>${xml(o.home)}</string>
89
+ <key>PATH</key>
90
+ <string>${xml(o.pathEnv)}</string>
91
+ </dict>
92
+ <key>RunAtLoad</key>
93
+ <true/>
94
+ <key>KeepAlive</key>
95
+ <true/>
96
+ <key>ThrottleInterval</key>
97
+ <integer>15</integer>
98
+ <key>StandardOutPath</key>
99
+ <string>${xml(path.join(o.logDir, 'pulse-daemon.log'))}</string>
100
+ <key>StandardErrorPath</key>
101
+ <string>${xml(path.join(o.logDir, 'pulse-daemon.err.log'))}</string>
102
+ </dict>
103
+ </plist>
104
+ `;
105
+ }
106
+ /** systemd splits ExecStart on spaces; a path with one must be quoted, and a literal % doubled. */
107
+ function systemdArg(value) {
108
+ const escaped = value.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
109
+ return /[\s"']/.test(value) ? `"${escaped}"` : escaped;
110
+ }
111
+ export function systemdUnit(o) {
112
+ return `[Unit]
113
+ Description=Agile Monster card runner
114
+ After=network-online.target
115
+ Wants=network-online.target
116
+
117
+ [Service]
118
+ ExecStart=${systemdArg(o.node)} ${systemdArg(o.entry)} pulse-daemon
119
+ WorkingDirectory=${o.home}
120
+ Environment=${systemdArg(`PATH=${o.pathEnv}`)}
121
+ Restart=always
122
+ RestartSec=15
123
+ StandardOutput=append:${path.join(o.logDir, 'pulse-daemon.log')}
124
+ StandardError=append:${path.join(o.logDir, 'pulse-daemon.err.log')}
125
+
126
+ [Install]
127
+ WantedBy=default.target
128
+ `;
129
+ }
130
+ /** The program an existing unit file starts (the script after the node binary), or null when unreadable. */
131
+ export function unitTarget(kind, content) {
132
+ if (kind === 'launchd') {
133
+ const block = content.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
134
+ if (!block)
135
+ return null;
136
+ const args = Array.from(block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)).map((m) => m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&'));
137
+ return args.find((a) => /mcp\.js$/.test(a)) || args[1] || args[0] || null;
138
+ }
139
+ const line = content.match(/^ExecStart=(.*)$/m);
140
+ if (!line)
141
+ return null;
142
+ const parts = line[1].match(/"(?:[^"\\]|\\.)*"|\S+/g) || [];
143
+ const clean = parts.map((p) => (p.startsWith('"') ? p.slice(1, -1).replace(/\\(["\\])/g, '$1') : p).replace(/%%/g, '%'));
144
+ return clean.find((a) => /mcp\.js$/.test(a)) || clean[1] || clean[0] || null;
145
+ }
146
+ export async function serviceStatus(deps = defaultServiceDeps()) {
147
+ const kind = serviceKind(deps.platform);
148
+ if (!kind)
149
+ return { supported: false, kind: null, unitPath: null, installed: false, managed: false, target: null, running: false };
150
+ const file = unitPath(kind, deps.home);
151
+ let content = '';
152
+ try {
153
+ content = fs.readFileSync(file, 'utf8');
154
+ }
155
+ catch { }
156
+ const installed = content !== '';
157
+ const target = installed ? unitTarget(kind, content) : null;
158
+ const managed = !!target && path.resolve(target) === path.resolve(runnerEntry(deps.amHome));
159
+ let running = false;
160
+ if (installed) {
161
+ if (kind === 'launchd') {
162
+ const res = deps.uid === null ? null : await deps.exec('launchctl', ['print', `gui/${deps.uid}/${SERVICE_LABEL}`], { timeoutMs: 15000 });
163
+ running = !!res && res.ok && /state = running/.test(res.stdout);
164
+ }
165
+ else {
166
+ const res = await deps.exec('systemctl', ['--user', 'is-active', SYSTEMD_UNIT], { timeoutMs: 15000 });
167
+ running = res.stdout.trim() === 'active';
168
+ }
169
+ }
170
+ return { supported: true, kind, unitPath: file, installed, managed, target, running };
171
+ }
172
+ const KEEP_OPEN_HINT = 'Until then, cards are worked while your AI coding tool is open in the project folder, or while this runs in a terminal: npx -y @agilemonster/mcp-server pulse-daemon';
173
+ export async function installService(options, deps = defaultServiceDeps()) {
174
+ const kind = serviceKind(deps.platform);
175
+ if (!kind) {
176
+ return {
177
+ ok: false,
178
+ outcome: 'unsupported',
179
+ message: `The background runner cannot be installed as a service on this system yet (${deps.platform}). ${KEEP_OPEN_HINT}`,
180
+ };
181
+ }
182
+ if (!options.hasApiKey) {
183
+ return {
184
+ ok: false,
185
+ outcome: 'failed',
186
+ message: 'No API key is saved on this computer yet. Run `npx -y @agilemonster/mcp-server bootstrap` in your project folder first.',
187
+ };
188
+ }
189
+ const status = await serviceStatus(deps);
190
+ if (status.installed && !status.managed && !options.force) {
191
+ return {
192
+ ok: true,
193
+ outcome: 'kept',
194
+ message: `A background runner is already set up on this computer (${status.target || status.unitPath}), so it was left as it is.` +
195
+ ' To replace it with the standard one: npx -y @agilemonster/mcp-server service install --force',
196
+ };
197
+ }
198
+ if (!deps.npm) {
199
+ return { ok: false, outcome: 'failed', message: `npm was not found, so the runner could not be installed. ${KEEP_OPEN_HINT}` };
200
+ }
201
+ if (kind === 'systemd') {
202
+ const probe = await deps.exec('systemctl', ['--user', 'show-environment'], { timeoutMs: 15000 });
203
+ if (!probe.ok) {
204
+ return {
205
+ ok: false,
206
+ outcome: 'unsupported',
207
+ message: `This system has no systemd user session, so the background runner could not be installed as a service. ${KEEP_OPEN_HINT}`,
208
+ };
209
+ }
210
+ }
211
+ // 1. The service's own copy of the package
212
+ const dir = runnerDir(deps.amHome);
213
+ const entry = runnerEntry(deps.amHome);
214
+ const logDir = path.join(deps.amHome, 'logs');
215
+ try {
216
+ fs.mkdirSync(dir, { recursive: true });
217
+ fs.mkdirSync(logDir, { recursive: true });
218
+ const manifest = path.join(dir, 'package.json');
219
+ if (!fs.existsSync(manifest)) {
220
+ fs.writeFileSync(manifest, JSON.stringify({ name: 'agilemonster-runner', private: true }, null, 2), 'utf8');
221
+ }
222
+ }
223
+ catch (err) {
224
+ return { ok: false, outcome: 'failed', message: `Could not create ${dir}: ${err instanceof Error ? err.message : String(err)}` };
225
+ }
226
+ const install = await deps.exec(deps.npm, updateArgs(dir), { cwd: dir, timeoutMs: 5 * 60 * 1000 });
227
+ if (!install.ok || !fs.existsSync(entry)) {
228
+ return { ok: false, outcome: 'failed', message: `Installing the runner failed: ${install.tail || 'npm did not produce the package'}. ${KEEP_OPEN_HINT}` };
229
+ }
230
+ // 2. The unit that starts it
231
+ const file = unitPath(kind, deps.home);
232
+ const unitOptions = { node: deps.node, entry, home: deps.home, logDir, pathEnv: deps.pathEnv };
233
+ try {
234
+ fs.mkdirSync(path.dirname(file), { recursive: true });
235
+ fs.writeFileSync(file, kind === 'launchd' ? launchdPlist(unitOptions) : systemdUnit(unitOptions), 'utf8');
236
+ }
237
+ catch (err) {
238
+ return { ok: false, outcome: 'failed', message: `Could not write ${file}: ${err instanceof Error ? err.message : String(err)}` };
239
+ }
240
+ // 3. Start it now and at every login
241
+ if (kind === 'launchd') {
242
+ if (deps.uid === null)
243
+ return { ok: false, outcome: 'failed', message: 'Could not determine the current user to start the service for.' };
244
+ const domain = `gui/${deps.uid}`;
245
+ await deps.exec('launchctl', ['bootout', `${domain}/${SERVICE_LABEL}`], { timeoutMs: 20000 }); // an older copy; fine if absent
246
+ let start = await deps.exec('launchctl', ['bootstrap', domain, file], { timeoutMs: 20000 });
247
+ if (!start.ok)
248
+ start = await deps.exec('launchctl', ['load', '-w', file], { timeoutMs: 20000 });
249
+ if (!start.ok) {
250
+ return { ok: false, outcome: 'failed', message: `The runner was installed but macOS would not start it: ${start.tail}. Log out and in again, or run: launchctl load -w "${file}"` };
251
+ }
252
+ }
253
+ else {
254
+ await deps.exec('systemctl', ['--user', 'daemon-reload'], { timeoutMs: 20000 });
255
+ const start = await deps.exec('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT], { timeoutMs: 30000 });
256
+ if (!start.ok) {
257
+ return { ok: false, outcome: 'failed', message: `The runner was installed but systemd would not start it: ${start.tail}` };
258
+ }
259
+ // Keep it running when nobody is logged in at the desktop (best effort: needs no password on most systems)
260
+ const user = os.userInfo().username;
261
+ if (which('loginctl'))
262
+ await deps.exec('loginctl', ['enable-linger', user], { timeoutMs: 15000 });
263
+ }
264
+ return {
265
+ ok: true,
266
+ outcome: 'installed',
267
+ message: 'The background runner is installed and running. It starts with this computer and works cards queued from the board, with no coding tool open.',
268
+ };
269
+ }
270
+ export async function uninstallService(options = {}, deps = defaultServiceDeps()) {
271
+ const status = await serviceStatus(deps);
272
+ if (!status.supported || !status.kind || !status.unitPath) {
273
+ return { ok: false, outcome: 'unsupported', message: 'There is no background runner service on this system.' };
274
+ }
275
+ if (!status.installed)
276
+ return { ok: true, outcome: 'absent', message: 'No background runner service is installed.' };
277
+ if (!status.managed && !options.force) {
278
+ return {
279
+ ok: true,
280
+ outcome: 'kept',
281
+ message: `The runner service on this computer was not installed by this command (${status.target || status.unitPath}), so it was left as it is. Add --force to remove it anyway.`,
282
+ };
283
+ }
284
+ if (status.kind === 'launchd') {
285
+ if (deps.uid !== null)
286
+ await deps.exec('launchctl', ['bootout', `gui/${deps.uid}/${SERVICE_LABEL}`], { timeoutMs: 20000 });
287
+ }
288
+ else {
289
+ await deps.exec('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT], { timeoutMs: 30000 });
290
+ }
291
+ try {
292
+ fs.rmSync(status.unitPath, { force: true });
293
+ if (status.managed)
294
+ fs.rmSync(runnerDir(deps.amHome), { recursive: true, force: true });
295
+ }
296
+ catch (err) {
297
+ return { ok: false, outcome: 'failed', message: `Stopped the service but could not remove its files: ${err instanceof Error ? err.message : String(err)}` };
298
+ }
299
+ if (status.kind === 'systemd')
300
+ await deps.exec('systemctl', ['--user', 'daemon-reload'], { timeoutMs: 20000 });
301
+ return { ok: true, outcome: 'removed', message: 'The background runner service was stopped and removed. Cards are no longer worked in the background on this computer.' };
302
+ }
303
+ /** npm arguments that install or update the service's own copy of the package. */
304
+ export function updateArgs(dir, version = 'latest') {
305
+ return ['install', '--prefix', dir, '--no-audit', '--no-fund', '--loglevel=error', `${PACKAGE}@${version}`];
306
+ }
307
+ /**
308
+ * When this process is the service's own copy, the folder to update in place. Null for a copy started by
309
+ * npx, a global install or a source checkout: those update the way they were installed.
310
+ */
311
+ export function managedRunnerDir(runningFile, amHome = agileMonsterHome()) {
312
+ const dir = runnerDir(amHome);
313
+ const resolved = path.resolve(runningFile);
314
+ return resolved.startsWith(path.resolve(dir) + path.sep) ? dir : null;
315
+ }
316
+ export async function describeService(deps = defaultServiceDeps()) {
317
+ const s = await serviceStatus(deps);
318
+ if (!s.supported)
319
+ return `Background runner: not available as a service on this system (${deps.platform}). ${KEEP_OPEN_HINT}`;
320
+ if (!s.installed)
321
+ return 'Background runner: not installed. Cards are worked only while your AI coding tool is open in the project folder.\nInstall it: npx -y @agilemonster/mcp-server service install';
322
+ const lines = [`Background runner: installed${s.running ? ' and running' : ', but not running right now'}.`];
323
+ lines.push(s.managed ? ` It starts with this computer and keeps itself up to date (${s.unitPath}).` : ` It was set up by hand and runs ${s.target || 'an unknown program'} (${s.unitPath}).`);
324
+ if (!s.running)
325
+ lines.push(' Start it again: npx -y @agilemonster/mcp-server service install' + (s.managed ? '' : ' --force'));
326
+ return lines.join('\n');
327
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilemonster/mcp-server",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "type": "module",
5
5
  "description": "Official Model Context Protocol (MCP) server & Orchestrator for Agile Monster (Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf)",
6
6
  "main": "dist/server.js",
@@ -47,6 +47,9 @@
47
47
  ],
48
48
  "author": "Agile Monster",
49
49
  "license": "MIT",
50
+ "engines": {
51
+ "node": ">=18.14"
52
+ },
50
53
  "dependencies": {
51
54
  "@modelcontextprotocol/sdk": "^1.6.1"
52
55
  },