@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 +39 -8
- package/bin/mcp.js +25 -1
- package/bin/orchestrator.js +1 -0
- package/dist/agent-engine.d.ts +4 -0
- package/dist/agent-engine.js +14 -2
- package/dist/client.d.ts +17 -3
- package/dist/client.js +24 -5
- package/dist/machine.d.ts +25 -0
- package/dist/machine.js +32 -0
- package/dist/orchestrator.d.ts +2 -0
- package/dist/orchestrator.js +33 -7
- package/dist/server.d.ts +10 -1
- package/dist/server.js +68 -26
- package/dist/service.d.ts +82 -0
- package/dist/service.js +327 -0
- package/package.json +4 -1
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
|
-
|
|
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
|
-
## 📁
|
|
63
|
+
## 📁 Connect a project folder (`bootstrap`)
|
|
62
64
|
|
|
63
|
-
|
|
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
|
|
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.
|
|
97
|
-
|
|
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
|
-
|
|
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);
|
package/bin/orchestrator.js
CHANGED
package/dist/agent-engine.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent-engine.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/machine.js
ADDED
|
@@ -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
|
+
}
|
package/dist/orchestrator.d.ts
CHANGED
|
@@ -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;
|
package/dist/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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(' 🚀
|
|
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.
|
|
322
|
-
console.log(
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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.
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
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:
|
|
3014
|
+
boardId: homeBoardId || undefined,
|
|
2975
3015
|
provider: 'any',
|
|
2976
|
-
projectName: config
|
|
2977
|
-
projectPath:
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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 ===
|
|
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([
|
|
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>;
|
package/dist/service.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/</g, '<').replace(/>/g, '>').replace(/&/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.
|
|
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
|
},
|