@agilemonster/mcp-server 1.0.25 → 1.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -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/branch-health.d.ts +118 -0
- package/dist/branch-health.js +302 -0
- package/dist/client.d.ts +17 -3
- package/dist/client.js +24 -5
- package/dist/deploy-memory.d.ts +17 -0
- package/dist/deploy-memory.js +20 -0
- package/dist/deploy-runner.d.ts +9 -0
- package/dist/deploy-runner.js +43 -4
- 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 +13 -1
- package/dist/server.js +197 -34
- package/dist/service.d.ts +82 -0
- package/dist/service.js +327 -0
- package/package.json +4 -1
package/dist/server.js
CHANGED
|
@@ -8,13 +8,36 @@ 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';
|
|
14
|
-
import { addPendingVerification, getCardState, loadMemory, recordAttempt, removePendingVerification, setCardState, setExplicitProfile, } from './deploy-memory.js';
|
|
16
|
+
import { addPendingVerification, getBranchQuestion, getCardState, loadMemory, recordAttempt, removePendingVerification, setBranchQuestion, setCardState, setExplicitProfile, } from './deploy-memory.js';
|
|
17
|
+
import { describeBlockedBranch, describePushConflict, parseBranchDirective, prepareBranch, shipOnlyCardCommits, startFromRemote, } from './branch-health.js';
|
|
15
18
|
import { checkCiOnce, describeFailureForHuman, ensureLocalGitExcludes, parseDeployDirective, runDeployment, } from './deploy-runner.js';
|
|
16
19
|
import { baselineFor, buildAgentPrompt, changesSince, clearBaseline, engineLabelFor, engineStatuses, markNewFilesForDiff, parseAgentReport, proseOf, resolveEngines, restoreWorkingTree, runEngine, } from './agent-engine.js';
|
|
17
20
|
import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath, errorMessage, exec, log, readJson, which, writeJsonAtomic, } from './runtime.js';
|
|
21
|
+
/**
|
|
22
|
+
* The answers a deploy question accepts, as reply buttons: nobody should have to type `deploy: ./deploy.sh`.
|
|
23
|
+
* A first-time route gets "yes, this way" and "I deploy it myself"; a failure gets "try again".
|
|
24
|
+
*/
|
|
25
|
+
export function deployReplyOptions(res) {
|
|
26
|
+
// A refused push has real ways out; "try again" on its own could never succeed
|
|
27
|
+
if (res.conflict)
|
|
28
|
+
return describePushConflict(res.conflict).options;
|
|
29
|
+
if (res.stage === 'git')
|
|
30
|
+
return [{ label: 'I fixed it, try again', reply: 'retry' }];
|
|
31
|
+
if (res.errorKind === 'needs_confirmation') {
|
|
32
|
+
const route = (res.command || res.method || '').trim();
|
|
33
|
+
const options = [];
|
|
34
|
+
if (route)
|
|
35
|
+
options.push({ label: 'Yes, deploy this way', reply: `deploy: ${route}` });
|
|
36
|
+
options.push({ label: 'I deploy it myself', reply: 'deploy: manual' });
|
|
37
|
+
return options;
|
|
38
|
+
}
|
|
39
|
+
return [{ label: 'Try again', reply: 'retry' }];
|
|
40
|
+
}
|
|
18
41
|
export function createServer(client = new AgileMonsterClient()) {
|
|
19
42
|
const server = new Server({
|
|
20
43
|
name: 'agilemonster-mcp-server',
|
|
@@ -263,6 +286,11 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
263
286
|
type: 'string',
|
|
264
287
|
description: 'The question for the user.',
|
|
265
288
|
},
|
|
289
|
+
options: {
|
|
290
|
+
type: 'array',
|
|
291
|
+
items: { type: 'string' },
|
|
292
|
+
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.',
|
|
293
|
+
},
|
|
266
294
|
},
|
|
267
295
|
required: ['card_id', 'provider', 'question'],
|
|
268
296
|
},
|
|
@@ -640,7 +668,10 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
|
|
|
640
668
|
const cardId = args.card_id;
|
|
641
669
|
const provider = args.provider || projectConfig?.provider || 'antigravity';
|
|
642
670
|
const question = args.question;
|
|
643
|
-
const
|
|
671
|
+
const answers = Array.isArray(args.options)
|
|
672
|
+
? args.options.map((o) => String(o).trim()).filter((o) => o.length > 0 && o.length <= 80).slice(0, 4)
|
|
673
|
+
: [];
|
|
674
|
+
const card = await client.askQuestion(cardId, provider, question, answers);
|
|
644
675
|
releaseClaim(cardId);
|
|
645
676
|
return {
|
|
646
677
|
content: [
|
|
@@ -1015,11 +1046,16 @@ function triggerBackgroundSelfUpdate(targetVersion) {
|
|
|
1015
1046
|
catch { }
|
|
1016
1047
|
}
|
|
1017
1048
|
}
|
|
1018
|
-
// 2.
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1049
|
+
// 2. Fetch the new version, then exit once idle so launchd/systemd starts it. The background service
|
|
1050
|
+
// updates its own copy in place; a runner started any other way refreshes the global install / npx cache.
|
|
1051
|
+
const ownDir = managedRunnerDir(fileURLToPath(import.meta.url));
|
|
1052
|
+
const npm = which('npm');
|
|
1053
|
+
const child = ownDir && npm
|
|
1054
|
+
? spawn(npm, updateArgs(ownDir), { stdio: 'ignore', detached: true, cwd: ownDir })
|
|
1055
|
+
: 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'], {
|
|
1056
|
+
stdio: 'ignore',
|
|
1057
|
+
detached: true,
|
|
1058
|
+
});
|
|
1023
1059
|
child.on('exit', () => {
|
|
1024
1060
|
appendIdeActivityLog(cwd, {
|
|
1025
1061
|
cardId: 'system-update',
|
|
@@ -1579,7 +1615,7 @@ async function runRedeployOnlyCard(client, card, projectPath, provider, workerLa
|
|
|
1579
1615
|
pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
|
|
1580
1616
|
}));
|
|
1581
1617
|
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)}
|
|
1618
|
+
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
1619
|
return false;
|
|
1584
1620
|
}
|
|
1585
1621
|
if (res.pending && res.pendingVerification)
|
|
@@ -1643,6 +1679,86 @@ function removeTaskLock(lockFile) {
|
|
|
1643
1679
|
}
|
|
1644
1680
|
catch { }
|
|
1645
1681
|
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Look at the project's branch before any work is built on it.
|
|
1684
|
+
*
|
|
1685
|
+
* 'go' the folder is fine (or was brought up to date, which is noted on the card)
|
|
1686
|
+
* 'fresh' the owner chose to start from the remote's version: unpushed commits are on a backup branch
|
|
1687
|
+
* 'asked' the branch cannot be pushed as it is, and the card now carries the question
|
|
1688
|
+
*
|
|
1689
|
+
* A check that cannot be made (no Git, no remote, offline) never holds a card up.
|
|
1690
|
+
*/
|
|
1691
|
+
async function ensureBranchReady(client, card, projectPath, provider, workerLabel, lockFile, replyAfter) {
|
|
1692
|
+
let prep;
|
|
1693
|
+
try {
|
|
1694
|
+
prep = await prepareBranch(projectPath);
|
|
1695
|
+
}
|
|
1696
|
+
catch (err) {
|
|
1697
|
+
log.warn('branch.check_failed', { card: card.id, project: projectPath, error: errorMessage(err) });
|
|
1698
|
+
return 'go';
|
|
1699
|
+
}
|
|
1700
|
+
const pending = getBranchQuestion(projectPath, card.id);
|
|
1701
|
+
if (prep.status !== 'blocked') {
|
|
1702
|
+
if (pending)
|
|
1703
|
+
setBranchQuestion(projectPath, card.id, null);
|
|
1704
|
+
if (prep.status === 'updated') {
|
|
1705
|
+
log.info('branch.updated', { card: card.id, project: projectPath, branch: prep.health.branch, behind: prep.health.behind, ahead: prep.health.ahead });
|
|
1706
|
+
try {
|
|
1707
|
+
await client.addProgress(card.id, provider, prep.note);
|
|
1708
|
+
}
|
|
1709
|
+
catch { }
|
|
1710
|
+
}
|
|
1711
|
+
return 'go';
|
|
1712
|
+
}
|
|
1713
|
+
const health = prep.health;
|
|
1714
|
+
log.info('branch.blocked', { card: card.id, project: projectPath, branch: health.branch, behind: health.behind, ahead: health.ahead, files: health.conflictFiles.slice(0, 5) });
|
|
1715
|
+
// Only a reply written after the question (or after the failed push that led here) is an answer to it
|
|
1716
|
+
const since = [pending?.at, replyAfter].filter((t) => Boolean(t)).sort().pop() || null;
|
|
1717
|
+
let reply = null;
|
|
1718
|
+
if (since) {
|
|
1719
|
+
try {
|
|
1720
|
+
const human = latestHumanComment(await client.listComments(card.id));
|
|
1721
|
+
reply = human && commentIsAfter(human, since) ? human : null;
|
|
1722
|
+
}
|
|
1723
|
+
catch (err) {
|
|
1724
|
+
log.warn('card.comments_unavailable', { card: card.id, error: errorMessage(err) });
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
let prefix = '';
|
|
1728
|
+
if (reply && parseBranchDirective(reply.text) === 'fresh') {
|
|
1729
|
+
// Moving commits around in the project folder is for the person whose computer this is to decide
|
|
1730
|
+
const ownerId = await machineOwnerId(client);
|
|
1731
|
+
const authorId = reply.user_id ? String(reply.user_id) : '';
|
|
1732
|
+
if (!ownerId || !authorId || authorId !== ownerId) {
|
|
1733
|
+
prefix = 'Only the person whose computer runs this agent can decide what happens to the commits in its project folder. Please ask them to choose here.\n\n';
|
|
1734
|
+
}
|
|
1735
|
+
else {
|
|
1736
|
+
const fresh = await startFromRemote(projectPath);
|
|
1737
|
+
if (fresh.ok) {
|
|
1738
|
+
setBranchQuestion(projectPath, card.id, null);
|
|
1739
|
+
try {
|
|
1740
|
+
await client.addProgress(card.id, provider, `Done: the ${fresh.setAside === 1 ? 'unpushed commit is' : `${fresh.setAside} unpushed commits are`} safe on the branch \`${fresh.backup}\`, and the project folder now matches ${health.remoteLabel}. Files that only ${fresh.setAside === 1 ? 'that commit' : 'those commits'} added are on that branch too, and no longer in the folder. Starting on the card.`);
|
|
1741
|
+
}
|
|
1742
|
+
catch { }
|
|
1743
|
+
return 'fresh';
|
|
1744
|
+
}
|
|
1745
|
+
prefix = `I could not do that: ${fresh.error}. Nothing was moved.\n\n`;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
const { question, options } = describeBlockedBranch(health);
|
|
1749
|
+
removeTaskLock(lockFile);
|
|
1750
|
+
setBranchQuestion(projectPath, card.id, new Date().toISOString());
|
|
1751
|
+
appendIdeActivityLog(projectPath, {
|
|
1752
|
+
cardId: card.id,
|
|
1753
|
+
cardTitle: card.title,
|
|
1754
|
+
agentName: workerLabel,
|
|
1755
|
+
model: card.agent_model || 'auto',
|
|
1756
|
+
status: 'waiting_for_input',
|
|
1757
|
+
summary: `Not started: \`${health.branch}\` is ${health.behind} behind and ${health.ahead} ahead of ${health.remoteLabel}, and the two clash.`,
|
|
1758
|
+
});
|
|
1759
|
+
await client.askQuestion(card.id, provider, prefix + question, options);
|
|
1760
|
+
return 'asked';
|
|
1761
|
+
}
|
|
1646
1762
|
/**
|
|
1647
1763
|
* The code change for this card already exists — only its deployment failed. When the human replies
|
|
1648
1764
|
* ("retry", or "deploy: <method or command>"), redo ONLY the deployment. Previously the whole card was
|
|
@@ -1665,7 +1781,8 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
|
|
|
1665
1781
|
if (reply && !parsed)
|
|
1666
1782
|
return 'continue';
|
|
1667
1783
|
const pendingRoute = state.pending_confirmation || null;
|
|
1668
|
-
|
|
1784
|
+
// (so does "ship only this change", which moves commits around in their project folder)
|
|
1785
|
+
const choosesRoute = Boolean(parsed && (parsed.method || parsed.liveUrl || parsed.onlyThis || pendingRoute));
|
|
1669
1786
|
if (choosesRoute) {
|
|
1670
1787
|
// Choosing or confirming a route decides what is executed on THIS computer, so it has to come from the
|
|
1671
1788
|
// person whose account this machine runs under — not from anyone who can comment on the board.
|
|
@@ -1683,7 +1800,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
|
|
|
1683
1800
|
// Nothing is ever confirmed on the human's behalf.
|
|
1684
1801
|
removeTaskLock(lockFile);
|
|
1685
1802
|
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
|
|
1803
|
+
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
1804
|
return 'failed';
|
|
1688
1805
|
}
|
|
1689
1806
|
// No fresh reply at all: a re-queue of a deploy-failed card means "try the same deployment again".
|
|
@@ -1703,15 +1820,33 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
|
|
|
1703
1820
|
set_by: 'the machine owner',
|
|
1704
1821
|
});
|
|
1705
1822
|
}
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1823
|
+
// "Ship only this change": older unpushed commits go to a backup branch, this card's commit goes on top of the remote
|
|
1824
|
+
let onlyThisNote = '';
|
|
1825
|
+
if (directive.onlyThis) {
|
|
1826
|
+
const only = await shipOnlyCardCommits(projectPath, card.id);
|
|
1827
|
+
if (!only.ok) {
|
|
1828
|
+
removeTaskLock(lockFile);
|
|
1829
|
+
setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
|
|
1830
|
+
await client.askQuestion(card.id, provider, `I could not ship only this card's change: ${only.error}. Nothing was moved; the project folder is exactly as it was.\n\nIf you sort out the branch or push it yourself, reply **retry**: I will not redo the work, only push what is missing and check the deployment.`, [{ label: 'I fixed it, try again', reply: 'retry' }]);
|
|
1831
|
+
return 'failed';
|
|
1832
|
+
}
|
|
1833
|
+
onlyThisNote =
|
|
1834
|
+
`Putting only this card's change on top of the newer version and deploying it.` +
|
|
1835
|
+
(only.setAside > 0 ? ` The ${only.setAside === 1 ? 'older commit is' : `${only.setAside} older commits are`} safe on the branch \`${only.backup}\` and ${only.setAside === 1 ? 'is' : 'are'} not deployed. Files that only ${only.setAside === 1 ? 'that commit' : 'those commits'} added are on that branch too, and no longer in the folder.` : '');
|
|
1836
|
+
}
|
|
1837
|
+
await client.addProgress(card.id, provider, onlyThisNote
|
|
1838
|
+
? onlyThisNote
|
|
1839
|
+
: directive.method
|
|
1840
|
+
? `Got it — deploying with **${methodLabel(directive.method)}**${directive.command ? ` (\`${directive.command}\`)` : ''}. I've saved this as the deployment route for this project, so future cards use it automatically.`
|
|
1841
|
+
: 'Retrying the deployment of the change I already made (no code is being regenerated).');
|
|
1709
1842
|
const res = await runDeployment({
|
|
1710
1843
|
projectPath,
|
|
1711
1844
|
title: card.title || 'update',
|
|
1712
1845
|
cardId: card.id,
|
|
1713
1846
|
files: state.files,
|
|
1714
|
-
|
|
1847
|
+
// When the change never reached the remote, pushing it (or finding that a person already did) IS the retry:
|
|
1848
|
+
// an empty "trigger redeploy" commit would deploy production a second time for nothing.
|
|
1849
|
+
redeploy: state.failed_stage !== 'git',
|
|
1715
1850
|
bound: boundProfileOf(card, await machineOwnerId(client)),
|
|
1716
1851
|
override: directive.method ? { method: directive.method, command: directive.command } : null,
|
|
1717
1852
|
});
|
|
@@ -1721,6 +1856,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
|
|
|
1721
1856
|
...state,
|
|
1722
1857
|
attempts: state.attempts + 1,
|
|
1723
1858
|
last_error: res.errorDetail,
|
|
1859
|
+
failed_stage: res.stage,
|
|
1724
1860
|
at: new Date().toISOString(),
|
|
1725
1861
|
pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
|
|
1726
1862
|
});
|
|
@@ -1734,9 +1870,9 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
|
|
|
1734
1870
|
summary: `Deployment retry failed: ${res.errorDetail.slice(0, 500)}`,
|
|
1735
1871
|
files: state.files,
|
|
1736
1872
|
});
|
|
1737
|
-
await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation'
|
|
1873
|
+
await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' || res.stage === 'git'
|
|
1738
1874
|
? describeFailureForHuman(res)
|
|
1739
|
-
: `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}
|
|
1875
|
+
: `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}`, deployReplyOptions(res));
|
|
1740
1876
|
return 'failed';
|
|
1741
1877
|
}
|
|
1742
1878
|
if (res.pending && res.pendingVerification) {
|
|
@@ -1904,7 +2040,7 @@ async function produceChangeWithAgent(run) {
|
|
|
1904
2040
|
return files.length > 0;
|
|
1905
2041
|
};
|
|
1906
2042
|
run.rollback.fn = async () => revert((await changesSince(projectPath, baseline)).changed);
|
|
1907
|
-
const backToHuman = async (question, summary, files) => {
|
|
2043
|
+
const backToHuman = async (question, summary, files, options = []) => {
|
|
1908
2044
|
run.rollback.fn = null;
|
|
1909
2045
|
removeTaskLock(lockFile);
|
|
1910
2046
|
appendIdeActivityLog(projectPath, {
|
|
@@ -1916,9 +2052,10 @@ async function produceChangeWithAgent(run) {
|
|
|
1916
2052
|
summary,
|
|
1917
2053
|
files,
|
|
1918
2054
|
});
|
|
1919
|
-
await client.askQuestion(card.id, provider, question);
|
|
2055
|
+
await client.askQuestion(card.id, provider, question, options);
|
|
1920
2056
|
return 'handled';
|
|
1921
2057
|
};
|
|
2058
|
+
const TRY_AGAIN = [{ label: 'Try again', reply: 'Please try again.' }];
|
|
1922
2059
|
const prompt = buildAgentPrompt({
|
|
1923
2060
|
title: card.title || '',
|
|
1924
2061
|
description: card.description || '',
|
|
@@ -1974,11 +2111,11 @@ async function produceChangeWithAgent(run) {
|
|
|
1974
2111
|
await revert(change.changed);
|
|
1975
2112
|
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
2113
|
? '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);
|
|
2114
|
+
: '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
2115
|
}
|
|
1979
2116
|
if (!result.ok) {
|
|
1980
2117
|
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'}`, []);
|
|
2118
|
+
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
2119
|
}
|
|
1983
2120
|
let report = parseAgentReport(result.text);
|
|
1984
2121
|
if (report.status === 'blocked' || change.changed.length === 0) {
|
|
@@ -1986,7 +2123,7 @@ async function produceChangeWithAgent(run) {
|
|
|
1986
2123
|
const said = report.question || report.summary || proseOf(result.text);
|
|
1987
2124
|
return backToHuman(report.status === 'blocked' && report.question
|
|
1988
2125
|
? 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)}`, []);
|
|
2126
|
+
: `${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
2127
|
}
|
|
1991
2128
|
// 2. The runner's own build gate. The agent built it too, so a failure here is usually this machine's
|
|
1992
2129
|
// environment — check that before sending the agent after an error that was never its own.
|
|
@@ -2032,7 +2169,7 @@ async function produceChangeWithAgent(run) {
|
|
|
2032
2169
|
const stillForeign = change.overlapping.filter((f) => !carryOverFiles.includes(f));
|
|
2033
2170
|
if (!gate.ok || change.changed.length === 0 || stillForeign.length > 0) {
|
|
2034
2171
|
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`, []);
|
|
2172
|
+
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
2173
|
}
|
|
2037
2174
|
}
|
|
2038
2175
|
}
|
|
@@ -2411,6 +2548,18 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
2411
2548
|
if (isRedeployOnlyCard(card)) {
|
|
2412
2549
|
return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
|
|
2413
2550
|
}
|
|
2551
|
+
// 1c. Can work be built and pushed from this folder? Look BEFORE the agent spends minutes on a branch that is
|
|
2552
|
+
// behind its remote — and ask now, in plain words, if a person has to decide something first.
|
|
2553
|
+
let earlierSummary = priorState?.summary || null;
|
|
2554
|
+
const branchReady = await ensureBranchReady(client, card, projectPath, provider, workerLabel, lockFile, priorState?.at || null);
|
|
2555
|
+
if (branchReady === 'asked')
|
|
2556
|
+
return false;
|
|
2557
|
+
if (branchReady === 'fresh') {
|
|
2558
|
+
// The local commits (this card's earlier attempt included) are on a backup branch: start the card over
|
|
2559
|
+
carryOverFiles = [];
|
|
2560
|
+
earlierSummary = null;
|
|
2561
|
+
setCardState(projectPath, card.id, null);
|
|
2562
|
+
}
|
|
2414
2563
|
const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card, await machineOwnerId(client)), memory: loadMemory(projectPath) });
|
|
2415
2564
|
const ownerId = await machineOwnerId(client);
|
|
2416
2565
|
const run = {
|
|
@@ -2425,7 +2574,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
2425
2574
|
originalContents,
|
|
2426
2575
|
ownerId,
|
|
2427
2576
|
conversation: await cardConversation(client, card, ownerId),
|
|
2428
|
-
earlierSummary
|
|
2577
|
+
earlierSummary,
|
|
2429
2578
|
engineNotes: [],
|
|
2430
2579
|
rollback,
|
|
2431
2580
|
};
|
|
@@ -2489,6 +2638,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
2489
2638
|
setCardState(projectPath, card.id, cardState('deploy_failed', {
|
|
2490
2639
|
commit: deployInfo.commitHash || null,
|
|
2491
2640
|
last_error: deployInfo.errorDetail,
|
|
2641
|
+
failed_stage: deployInfo.stage,
|
|
2492
2642
|
pending_confirmation: deployInfo.errorKind === 'needs_confirmation' ? { method: deployInfo.method, command: deployInfo.command } : null,
|
|
2493
2643
|
}));
|
|
2494
2644
|
removeTaskLock(lockFile);
|
|
@@ -2505,7 +2655,9 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
|
|
|
2505
2655
|
const changed = modifiedFiles.map((f) => `\`${f}\``).join(', ');
|
|
2506
2656
|
await client.askQuestion(card.id, provider, deployInfo.errorKind === 'needs_confirmation'
|
|
2507
2657
|
? `I made the code change (${changed}). One question before I deploy this project for the first time:\n\n${describeFailureForHuman(deployInfo)}`
|
|
2508
|
-
:
|
|
2658
|
+
: deployInfo.stage === 'git'
|
|
2659
|
+
? `${describeFailureForHuman(deployInfo)}\n\nFiles I changed for this card: ${changed}.`
|
|
2660
|
+
: `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
2661
|
return false;
|
|
2510
2662
|
}
|
|
2511
2663
|
if (deployInfo.pending && deployInfo.pendingVerification) {
|
|
@@ -2820,7 +2972,7 @@ async function processPendingVerifications(client, projectPaths) {
|
|
|
2820
2972
|
attempts: 1,
|
|
2821
2973
|
last_error: detail,
|
|
2822
2974
|
});
|
|
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
|
|
2975
|
+
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
2976
|
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
2977
|
}
|
|
2826
2978
|
else if (ageMs > 90 * 60 * 1000) {
|
|
@@ -2887,6 +3039,7 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
2887
3039
|
let holdsWorkerLock = false;
|
|
2888
3040
|
let journalsRecovered = false;
|
|
2889
3041
|
let consecutiveFailures = 0;
|
|
3042
|
+
let warnedNoKey = false;
|
|
2890
3043
|
const autoProvisionSavedBindings = (apiKey, bindings) => {
|
|
2891
3044
|
for (const b of bindings) {
|
|
2892
3045
|
const projPath = (b.project_path || '').trim();
|
|
@@ -2965,19 +3118,28 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
2965
3118
|
try {
|
|
2966
3119
|
const config = client.loadProjectConfig();
|
|
2967
3120
|
const apiKey = client.getApiKey();
|
|
2968
|
-
|
|
3121
|
+
// The background service is not started inside a project folder: it has no board of its own and
|
|
3122
|
+
// serves every folder linked on this machine. A coding tool's MCP server only pulses for its project.
|
|
3123
|
+
const homeBoardId = config?.board_id || '';
|
|
3124
|
+
if (!apiKey || (!homeBoardId && role !== 'daemon')) {
|
|
3125
|
+
if (role === 'daemon' && !apiKey && !warnedNoKey) {
|
|
3126
|
+
warnedNoKey = true;
|
|
3127
|
+
log.warn('daemon.no_api_key', { hint: 'run `npx -y @agilemonster/mcp-server bootstrap` in a project folder' });
|
|
3128
|
+
}
|
|
2969
3129
|
return;
|
|
2970
3130
|
}
|
|
3131
|
+
const homePath = homeBoardId ? config?.project_path || process.cwd() : '';
|
|
2971
3132
|
const includeDiscovery = pulseCount === 0 || pulseCount % 3 === 0;
|
|
2972
3133
|
pulseCount++;
|
|
2973
3134
|
const res = await client.pulse({
|
|
2974
|
-
boardId:
|
|
3135
|
+
boardId: homeBoardId || undefined,
|
|
2975
3136
|
provider: 'any',
|
|
2976
|
-
projectName: config
|
|
2977
|
-
projectPath:
|
|
3137
|
+
projectName: homeBoardId ? config?.project_name || path.basename(process.cwd()) : undefined,
|
|
3138
|
+
projectPath: homePath || undefined,
|
|
2978
3139
|
discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
|
|
2979
3140
|
discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
|
|
2980
3141
|
activeClaims: activeClaims(),
|
|
3142
|
+
machine: await machineReport(role).catch(() => undefined),
|
|
2981
3143
|
});
|
|
2982
3144
|
if (consecutiveFailures > 0) {
|
|
2983
3145
|
log.info('pulse.recovered', { after_failures: consecutiveFailures });
|
|
@@ -2987,13 +3149,14 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
2987
3149
|
autoProvisionSavedBindings(apiKey, res.saved_bindings);
|
|
2988
3150
|
}
|
|
2989
3151
|
const queued = res.queued_cards || [];
|
|
2990
|
-
|
|
3152
|
+
if (homeBoardId)
|
|
3153
|
+
syncIdeTaskRules(config?.board_name || 'Board', queued);
|
|
2991
3154
|
for (const card of queued) {
|
|
2992
3155
|
if (!notifiedCardIds.has(card.id)) {
|
|
2993
3156
|
notifiedCardIds.add(card.id);
|
|
2994
3157
|
const worker = card.agent_display_name || card.agent_provider || 'AI Agent';
|
|
2995
3158
|
const modelTag = card.agent_model && card.agent_model !== 'auto' ? ` [${card.agent_model}]` : '';
|
|
2996
|
-
sendDesktopNotification(`Agile Monster -> ${worker}${modelTag}`, `New task queued on ${config
|
|
3159
|
+
sendDesktopNotification(`Agile Monster -> ${worker}${modelTag}`, `New task queued on ${config?.board_name || 'Board'}: "${card.title}"`);
|
|
2997
3160
|
try {
|
|
2998
3161
|
if (server) {
|
|
2999
3162
|
await server.sendResourceUpdated({ uri: 'agilemonster://project/context' });
|
|
@@ -3013,7 +3176,7 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
3013
3176
|
const boundQueued = res.all_bound_queued_cards || [];
|
|
3014
3177
|
for (const qCard of boundQueued) {
|
|
3015
3178
|
const targetPath = (qCard.bound_project_path || '').trim() ||
|
|
3016
|
-
(qCard.board_id ===
|
|
3179
|
+
(homeBoardId && qCard.board_id === homeBoardId ? homePath : '');
|
|
3017
3180
|
if (targetPath && fs.existsSync(targetPath)) {
|
|
3018
3181
|
await executeQueuedCardAutonomously(client, qCard, targetPath);
|
|
3019
3182
|
}
|
|
@@ -3024,12 +3187,12 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
|
|
|
3024
3187
|
const boundDone = res.all_bound_done_cards || [];
|
|
3025
3188
|
for (const dCard of boundDone) {
|
|
3026
3189
|
const targetPath = (dCard.bound_project_path || '').trim() ||
|
|
3027
|
-
(dCard.board_id ===
|
|
3190
|
+
(homeBoardId && dCard.board_id === homeBoardId ? homePath : '');
|
|
3028
3191
|
if (targetPath && fs.existsSync(targetPath)) {
|
|
3029
3192
|
await deployApprovedCardIfNeeded(client, dCard, targetPath);
|
|
3030
3193
|
}
|
|
3031
3194
|
}
|
|
3032
|
-
const knownPaths = new Set([
|
|
3195
|
+
const knownPaths = new Set(homePath ? [homePath] : []);
|
|
3033
3196
|
for (const b of res.saved_bindings || []) {
|
|
3034
3197
|
if (b.project_path)
|
|
3035
3198
|
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>;
|